feat: request portal — bespoke forms + approval mechanism
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
import { getSP } from '../pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import { IList } from '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import '@pnp/sp/site-users/web';
|
||||
import '@pnp/sp/profiles';
|
||||
import { IApprovalRow } from './models/IApprovalRow';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
|
||||
// Внутренние имена колонок списка согласования (создаются провижинингом).
|
||||
const FIELDS = {
|
||||
approver: 'Approver', // «Согласующий» (User) → ApproverId
|
||||
status: 'Status', // «Статус» (Choice)
|
||||
ticket: 'Ticket', // «Заявка» (Lookup) → TicketId
|
||||
date: 'Date', // «Дата» (DateTime)
|
||||
comment: 'Comment', // «Комментарий» (Text)
|
||||
order: 'Order', // «Порядок» (Number)
|
||||
allowReassign: 'AllowReassignToOtherApprovers', // «Можно перепоручать» (Да/Нет)
|
||||
};
|
||||
|
||||
// Согласующий для записи в список.
|
||||
export interface IApproverToWrite {
|
||||
email: string;
|
||||
position: string;
|
||||
allowReassign: boolean;
|
||||
}
|
||||
|
||||
// Все запросы к списку «Согласование — <форма>». listTitle задаётся формой.
|
||||
export class ApprovalListService {
|
||||
constructor(private readonly listTitle: string) {}
|
||||
|
||||
private list(): IList {
|
||||
return getSP().web.lists.getByTitle(this.listTitle);
|
||||
}
|
||||
|
||||
// Пишет по строке на каждого согласующего с указанным статусом.
|
||||
// Порядок (Order) = позиция в массиве.
|
||||
public async createSteps(
|
||||
requestId: number,
|
||||
approvers: IApproverToWrite[],
|
||||
status: string
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < approvers.length; i++) {
|
||||
const a = approvers[i];
|
||||
if (!a.email) continue;
|
||||
const approverId = await this.resolveUserId(a.email);
|
||||
await this.list().items.add({
|
||||
Title: a.position || a.email,
|
||||
[`${FIELDS.approver}Id`]: approverId,
|
||||
[FIELDS.status]: status,
|
||||
[`${FIELDS.ticket}Id`]: requestId,
|
||||
[FIELDS.order]: i + 1,
|
||||
[FIELDS.allowReassign]: a.allowReassign,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляет одну строку согласования с заданным Order (для делегата).
|
||||
public async addApproverRow(
|
||||
requestId: number,
|
||||
approver: IApproverToWrite,
|
||||
order: number,
|
||||
status: string
|
||||
): Promise<void> {
|
||||
const approverId = await this.resolveUserId(approver.email);
|
||||
await this.list().items.add({
|
||||
Title: approver.position || approver.email,
|
||||
[`${FIELDS.approver}Id`]: approverId,
|
||||
[FIELDS.status]: status,
|
||||
[`${FIELDS.ticket}Id`]: requestId,
|
||||
[FIELDS.order]: order,
|
||||
[FIELDS.allowReassign]: approver.allowReassign,
|
||||
});
|
||||
}
|
||||
|
||||
// Строки согласования по заявке, в порядке согласования (по Order).
|
||||
public async getForRequest(requestId: number): Promise<IApprovalRow[]> {
|
||||
const rows = (await this.list()
|
||||
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||
.select('Id', FIELDS.status, FIELDS.order, FIELDS.allowReassign, `${FIELDS.approver}/Id`)
|
||||
.expand(FIELDS.approver)
|
||||
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||
|
||||
return rows.map((r) => {
|
||||
const approver = r[FIELDS.approver] as { Id?: number } | undefined;
|
||||
return {
|
||||
id: r.Id as number,
|
||||
status: (r[FIELDS.status] as string) || '',
|
||||
approverId: approver?.Id ?? 0,
|
||||
order: (r[FIELDS.order] as number) ?? 0,
|
||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Строки согласования для отображения матрицы (ФИО, должность, статус, дата, комментарий).
|
||||
public async getMatrixForRequest(requestId: number): Promise<IMatrixRow[]> {
|
||||
const rows = (await this.list()
|
||||
.items.filter(`${FIELDS.ticket}Id eq ${requestId}`)
|
||||
.select(
|
||||
'Id',
|
||||
'Title',
|
||||
FIELDS.status,
|
||||
FIELDS.date,
|
||||
FIELDS.comment,
|
||||
FIELDS.order,
|
||||
FIELDS.allowReassign,
|
||||
`${FIELDS.approver}/Title`
|
||||
)
|
||||
.expand(FIELDS.approver)
|
||||
.orderBy(FIELDS.order, true)()) as Array<Record<string, unknown>>;
|
||||
|
||||
return rows.map((r, i) => {
|
||||
const approver = r[FIELDS.approver] as { Title?: string } | undefined;
|
||||
return {
|
||||
order: (r[FIELDS.order] as number) ?? i + 1,
|
||||
who: approver?.Title || '',
|
||||
email: '',
|
||||
position: (r.Title as string) || '',
|
||||
status: (r[FIELDS.status] as string) || '',
|
||||
date: this.formatDate((r[FIELDS.date] as string) || ''),
|
||||
comment: (r[FIELDS.comment] as string) || '',
|
||||
allowReassign: (r[FIELDS.allowReassign] as boolean) ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Поиск пользователей сайта по части имени — для пикера согласующего.
|
||||
public async searchUsers(query: string): Promise<ISiteUser[]> {
|
||||
const q = query.trim();
|
||||
if (q.length < 2) return [];
|
||||
const escaped = q.replace(/'/g, "''");
|
||||
const users = (await getSP()
|
||||
.web.siteUsers.filter(`substringof('${escaped}',Title) and PrincipalType eq 1`)
|
||||
.select('Id', 'Title', 'Email', 'LoginName')
|
||||
.top(10)()) as Array<Record<string, unknown>>;
|
||||
return users.map((u) => ({
|
||||
id: u.Id as number,
|
||||
name: (u.Title as string) || '',
|
||||
email: (u.Email as string) || '',
|
||||
loginName: (u.LoginName as string) || '',
|
||||
}));
|
||||
}
|
||||
|
||||
// Должность пользователя из профиля SharePoint (SPS-JobTitle). '' если недоступно.
|
||||
public async getJobTitle(loginName: string): Promise<string> {
|
||||
if (!loginName) return '';
|
||||
try {
|
||||
const props = (await getSP().profiles.getPropertiesFor(loginName)) as {
|
||||
UserProfileProperties?: unknown;
|
||||
};
|
||||
const raw = props.UserProfileProperties;
|
||||
const list: Array<{ Key: string; Value: string }> = Array.isArray(raw)
|
||||
? (raw as Array<{ Key: string; Value: string }>)
|
||||
: ((raw as { results?: Array<{ Key: string; Value: string }> })?.results ?? []);
|
||||
const map: Record<string, string> = {};
|
||||
for (const p of list) map[p.Key] = p.Value;
|
||||
return map['SPS-JobTitle'] || map.Title || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Решение согласующего по строке: статус + дата + комментарий.
|
||||
public async setDecision(rowId: number, status: string, comment: string): Promise<void> {
|
||||
await this.list()
|
||||
.items.getById(rowId)
|
||||
.update({
|
||||
[FIELDS.status]: status,
|
||||
[FIELDS.date]: new Date().toISOString(),
|
||||
[FIELDS.comment]: comment,
|
||||
});
|
||||
}
|
||||
|
||||
// Удаляет все строки согласования по заявке (при возврате/повторной отправке).
|
||||
public async deleteForRequest(requestId: number): Promise<void> {
|
||||
const rows = await this.getForRequest(requestId);
|
||||
for (const row of rows) {
|
||||
await this.list().items.getById(row.id).delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Id текущего пользователя на сайте (для сравнения с полем «Согласующий»).
|
||||
public async getCurrentUserId(): Promise<number> {
|
||||
const user = await getSP().web.currentUser.select('Id')();
|
||||
return user.Id;
|
||||
}
|
||||
|
||||
// email → числовой Id пользователя сайта (для Person-поля «Согласующий»).
|
||||
private async resolveUserId(email: string): Promise<number> {
|
||||
const user = await this.ensure(email);
|
||||
return user.Id;
|
||||
}
|
||||
|
||||
// email → отображаемое имя (ФИО). Фолбэк на email, если имя недоступно.
|
||||
public async resolveUserName(email: string): Promise<string> {
|
||||
try {
|
||||
const user = await this.ensure(email);
|
||||
return user.Title || email;
|
||||
} catch {
|
||||
return email;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensure(email: string): Promise<{ Id: number; Title: string }> {
|
||||
const login = email.indexOf('|') >= 0 ? email : `i:0#.f|membership|${email}`;
|
||||
return getSP().web.ensureUser(login);
|
||||
}
|
||||
|
||||
private formatDate(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const dd = d.getDate() < 10 ? `0${d.getDate()}` : `${d.getDate()}`;
|
||||
const mm = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : `${d.getMonth() + 1}`;
|
||||
return `${dd}.${mm}.${d.getFullYear()}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, APPROVAL_API_BASE_URL } from '../config';
|
||||
import { IApiResponse, IRequestType, IResolvedRoute } from './models/IApprovalRouteApi';
|
||||
|
||||
// Все обращения к внешнему approval-route API — только здесь.
|
||||
// Клиент авторизуется через AadHttpClient (SSO по текущему пользователю).
|
||||
async function client(context: WebPartContext): Promise<AadHttpClient> {
|
||||
return context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||
}
|
||||
|
||||
// GET /api/request-types — список типов заявок (id + имя). Пригодится, чтобы
|
||||
// сопоставить форму с RequestTypeId, если он неизвестен заранее.
|
||||
export async function getRequestTypes(context: WebPartContext): Promise<IRequestType[]> {
|
||||
const c = await client(context);
|
||||
const response: HttpClientResponse = await c.get(
|
||||
`${APPROVAL_API_BASE_URL}/api/request-types`,
|
||||
AadHttpClient.configurations.v1
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`request-types: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body: IApiResponse<IRequestType[]> = await response.json();
|
||||
return body.data ?? [];
|
||||
}
|
||||
|
||||
// GET /api/template-routes/resolve — планируемый маршрут согласования для типа
|
||||
// заявки и пользователя. Бэкенд сам подставляет согласующих (executorEmail/position)
|
||||
// по ролям исходя из userEmail.
|
||||
export async function resolveRoute(
|
||||
context: WebPartContext,
|
||||
requestTypeId: number,
|
||||
userEmail: string
|
||||
): Promise<IResolvedRoute> {
|
||||
const c = await client(context);
|
||||
const url =
|
||||
`${APPROVAL_API_BASE_URL}/api/template-routes/resolve` +
|
||||
`?RequestTypeId=${requestTypeId}` +
|
||||
`&UserEmail=${encodeURIComponent(userEmail)}` +
|
||||
`&Status=Approval`;
|
||||
const response: HttpClientResponse = await c.get(url, AadHttpClient.configurations.v1);
|
||||
if (!response.ok) {
|
||||
throw new Error(`resolve: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body: IApiResponse<IResolvedRoute> = await response.json();
|
||||
return body.data;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { resolveRoute } from './approvalRoute.service';
|
||||
import { ApprovalListService, IApproverToWrite } from './approvalList.service';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
|
||||
// Планируемый маршрут для НОВОЙ заявки: resolve по email инициатора → строки матрицы.
|
||||
// Имена согласующих подтягиваются по executorEmail (бэкенд отдаёт email + должность).
|
||||
export async function getPlannedMatrix(
|
||||
context: WebPartContext,
|
||||
requestTypeId: number,
|
||||
userEmail: string,
|
||||
approval: ApprovalListService
|
||||
): Promise<IMatrixRow[]> {
|
||||
if (!userEmail) return [];
|
||||
const route = await resolveRoute(context, requestTypeId, userEmail);
|
||||
const ordered = route.steps.slice().sort((a, b) => a.orderNo - b.orderNo);
|
||||
return Promise.all(
|
||||
ordered.map(async (s) => ({
|
||||
order: s.orderNo,
|
||||
who: s.executorEmail ? await approval.resolveUserName(s.executorEmail) : '',
|
||||
email: s.executorEmail || '',
|
||||
position: s.position || '',
|
||||
status: '—',
|
||||
date: '',
|
||||
comment: '',
|
||||
allowReassign: s.allowReassignToOtherApprovers,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Записать строки согласующих для заявки с указанным статусом.
|
||||
// Матрица перезаписывается целиком (для повторной отправки/черновика).
|
||||
export async function writeApprovers(
|
||||
approval: ApprovalListService,
|
||||
requestId: number,
|
||||
approvers: IApproverToWrite[],
|
||||
status: string
|
||||
): Promise<void> {
|
||||
await approval.deleteForRequest(requestId);
|
||||
await approval.createSteps(requestId, approvers, status);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { getSP } from '../pnp/pnpjs';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import { ApprovalListService } from './approvalList.service';
|
||||
import { REQUEST_STATUS } from './models/statuses';
|
||||
import { IApprovalRow } from './models/IApprovalRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
|
||||
export type ApproverDecision = 'approve' | 'return' | 'reject';
|
||||
|
||||
// Решение согласующего → статус его строки в списке согласования.
|
||||
const ROW_STATUS: Record<ApproverDecision, string> = {
|
||||
approve: REQUEST_STATUS.approved,
|
||||
return: REQUEST_STATUS.returned,
|
||||
reject: REQUEST_STATUS.rejected,
|
||||
};
|
||||
|
||||
// Действия согласующего: определение его строки, применение решения,
|
||||
// делегирование и пересчёт статуса заявки в контентном списке.
|
||||
//
|
||||
// ВНИМАНИЕ: используется в режиме согласующего, который открывается по конкретной
|
||||
// заявке (requestId). Пока в портале нет маршрутизации на форму заявки, этот сервис
|
||||
// готов, но UI-режим согласующего ещё не подключён (см. план).
|
||||
export class ApproverService {
|
||||
constructor(
|
||||
private readonly approval: ApprovalListService,
|
||||
private readonly dataListTitle: string
|
||||
) {}
|
||||
|
||||
// Ожидающая строка текущего пользователя, если сейчас его очередь; иначе undefined.
|
||||
// Последовательное согласование: активна первая строка со статусом «На согласовании».
|
||||
public async getPendingRowForCurrentUser(requestId: number): Promise<IApprovalRow | undefined> {
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const userId = await this.approval.getCurrentUserId();
|
||||
const firstPending = rows.filter((r) => r.status === REQUEST_STATUS.onApproval)[0];
|
||||
if (firstPending && firstPending.approverId === userId) return firstPending;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Делегирование: согласующий добавляет делеганта ПЕРЕД собой (Order — между
|
||||
// предыдущей строкой и своей). Делегат согласует раньше и сам не делегирует.
|
||||
public async delegate(
|
||||
requestId: number,
|
||||
currentRowId: number,
|
||||
user: ISiteUser
|
||||
): Promise<void> {
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const current = rows.filter((r) => r.id === currentRowId)[0];
|
||||
if (!current) return;
|
||||
const before = rows.filter((r) => r.order < current.order);
|
||||
const prevOrder = before.length > 0 ? before[before.length - 1].order : current.order - 1;
|
||||
const newOrder = (prevOrder + current.order) / 2;
|
||||
const position = await this.approval.getJobTitle(user.loginName);
|
||||
await this.approval.addApproverRow(
|
||||
requestId,
|
||||
{ email: user.email, position, allowReassign: false },
|
||||
newOrder,
|
||||
REQUEST_STATUS.onApproval
|
||||
);
|
||||
}
|
||||
|
||||
// Применяет решение: обновляет строку и пересчитывает статус заявки.
|
||||
// Возвращает новый статус заявки.
|
||||
public async decide(
|
||||
requestId: number,
|
||||
rowId: number,
|
||||
decision: ApproverDecision,
|
||||
comment: string
|
||||
): Promise<string> {
|
||||
await this.approval.setDecision(rowId, ROW_STATUS[decision], comment);
|
||||
const rows = await this.approval.getForRequest(requestId);
|
||||
const requestStatus = this.computeRequestStatus(rows);
|
||||
await this.updateRequestStatus(requestId, requestStatus);
|
||||
return requestStatus;
|
||||
}
|
||||
|
||||
private computeRequestStatus(rows: IApprovalRow[]): string {
|
||||
if (rows.some((r) => r.status === REQUEST_STATUS.rejected)) return REQUEST_STATUS.rejected;
|
||||
if (rows.some((r) => r.status === REQUEST_STATUS.returned)) return REQUEST_STATUS.returned;
|
||||
if (rows.length > 0 && rows.every((r) => r.status === REQUEST_STATUS.approved)) {
|
||||
return REQUEST_STATUS.approved;
|
||||
}
|
||||
return REQUEST_STATUS.onApproval;
|
||||
}
|
||||
|
||||
private async updateRequestStatus(id: number, status: string): Promise<void> {
|
||||
await getSP().web.lists.getByTitle(this.dataListTitle).items.getById(id).update({
|
||||
Status: status,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Единая точка импорта механизма согласования.
|
||||
export { REQUEST_STATUS, REQUEST_STATUS_CHOICES } from './models/statuses';
|
||||
export type { ISiteUser } from './models/ISiteUser';
|
||||
export type { IMatrixRow } from './models/IMatrixRow';
|
||||
export type { IApprovalRow } from './models/IApprovalRow';
|
||||
export type {
|
||||
IRequestType,
|
||||
IResolvedRoute,
|
||||
IResolvedRouteStep,
|
||||
} from './models/IApprovalRouteApi';
|
||||
|
||||
export { getRequestTypes, resolveRoute } from './approvalRoute.service';
|
||||
export { ApprovalListService } from './approvalList.service';
|
||||
export type { IApproverToWrite } from './approvalList.service';
|
||||
export { ApproverService } from './approverDecision.service';
|
||||
export type { ApproverDecision } from './approverDecision.service';
|
||||
export { getPlannedMatrix, writeApprovers } from './approvalSubmission.service';
|
||||
export { useMatrixEditor } from './useMatrixEditor';
|
||||
export type { IUseMatrixEditor } from './useMatrixEditor';
|
||||
export { ApprovalMatrix } from './ui/ApprovalMatrix';
|
||||
export { UserPicker } from './ui/UserPicker';
|
||||
@@ -0,0 +1,32 @@
|
||||
// Типы внешнего approval-route API (api.tnsu.kz).
|
||||
|
||||
export type RouteType = 'None' | 'Parallel' | 'Sequential';
|
||||
export type ApprovalStatus = 'None' | 'Approval' | 'Execution';
|
||||
|
||||
// GET /api/request-types → { data: IRequestType[] }
|
||||
export interface IRequestType {
|
||||
id: number;
|
||||
name: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
// Обёртка ответа API (все ответы приходят в поле data).
|
||||
export interface IApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
// GET /api/template-routes/resolve → data
|
||||
export interface IResolvedRoute {
|
||||
routeId: number;
|
||||
routeType: RouteType;
|
||||
requiresControl: boolean;
|
||||
steps: IResolvedRouteStep[];
|
||||
}
|
||||
|
||||
export interface IResolvedRouteStep {
|
||||
id: number;
|
||||
orderNo: number;
|
||||
executorEmail: string | null;
|
||||
position: string | null;
|
||||
allowReassignToOtherApprovers: boolean;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Строка согласования из списка «Согласование — <форма>».
|
||||
export interface IApprovalRow {
|
||||
id: number;
|
||||
status: string;
|
||||
approverId: number; // Id пользователя сайта в поле «Согласующий»
|
||||
order: number; // колонка Order (очередь согласования)
|
||||
allowReassign: boolean; // AllowReassignToOtherApprovers
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Строка матрицы согласования для отображения и редактирования на форме.
|
||||
export interface IMatrixRow {
|
||||
order: number; // порядок согласования (колонка Order)
|
||||
who: string; // ФИО согласующего
|
||||
email: string; // email (для записи новых строк; '' для загруженных из списка)
|
||||
position: string; // должность/роль
|
||||
status: string; // статус согласования или «—» для планируемой
|
||||
date: string; // дата решения (дд.мм.гггг) или ''
|
||||
comment: string;
|
||||
allowReassign: boolean; // может ли этот согласующий добавить делеганта
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Пользователь сайта (результат поиска для пикера согласующего).
|
||||
export interface ISiteUser {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
loginName: string; // claims-логин (для чтения должности из профиля)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Значения Choice-колонки «Статус» — общий жизненный цикл заявки портала.
|
||||
// Одинаков для контентного списка (заявка) и списка согласования (строки маршрута).
|
||||
// В слое формы кодом ставятся Draft (черновик) и OnApproval (отправка на согласование);
|
||||
// Approved/Rejected/Returned выставляются при решениях согласующих; Execution/Closed —
|
||||
// зарезервированы под будущие этапы.
|
||||
export const REQUEST_STATUS = {
|
||||
draft: 'Черновик',
|
||||
onApproval: 'На согласовании',
|
||||
approved: 'Согласовано',
|
||||
rejected: 'Отклонено',
|
||||
returned: 'Возвращена на доработку',
|
||||
execution: 'Выполнение',
|
||||
closed: 'Закрыта',
|
||||
} as const;
|
||||
|
||||
// Полный набор значений для Choice-колонки при провижининге.
|
||||
export const REQUEST_STATUS_CHOICES: string[] = [
|
||||
REQUEST_STATUS.draft,
|
||||
REQUEST_STATUS.onApproval,
|
||||
REQUEST_STATUS.approved,
|
||||
REQUEST_STATUS.rejected,
|
||||
REQUEST_STATUS.returned,
|
||||
REQUEST_STATUS.execution,
|
||||
REQUEST_STATUS.closed,
|
||||
];
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react';
|
||||
import styles from './approval.module.scss';
|
||||
import { Icon } from '../../ui';
|
||||
import { IUseMatrixEditor } from '../useMatrixEditor';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
import { UserPicker } from './UserPicker';
|
||||
|
||||
export interface IApprovalMatrixProps {
|
||||
editor: IUseMatrixEditor;
|
||||
search: (query: string) => Promise<ISiteUser[]>;
|
||||
}
|
||||
|
||||
/** Таблица маршрута согласования + (если editable) правка порядка/состава. */
|
||||
export const ApprovalMatrix: React.FC<IApprovalMatrixProps> = ({ editor, search }) => {
|
||||
const { rows, loading, error, editable } = editor;
|
||||
|
||||
return (
|
||||
<div className={styles.matrixWrap}>
|
||||
<div className={styles.matrixTitle}>Маршрут согласования</div>
|
||||
{loading && <div className={styles.matrixNote}>Загрузка маршрута…</div>}
|
||||
{!loading && error && <div className={styles.matrixNote}>Маршрут недоступен: {error}</div>}
|
||||
{!loading && !error && rows.length === 0 && (
|
||||
<div className={styles.matrixNote}>Согласующие не заданы.</div>
|
||||
)}
|
||||
{!loading && !error && rows.length > 0 && (
|
||||
<table className={styles.matrix}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Согласующий</th>
|
||||
<th>Должность</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата</th>
|
||||
{editable && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{r.who}</td>
|
||||
<td>{r.position}</td>
|
||||
<td>{r.status}</td>
|
||||
<td>{r.date}</td>
|
||||
{editable && (
|
||||
<td className={styles.matrixActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === 0}
|
||||
onClick={() => editor.moveUp(i)}
|
||||
title="Выше"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" className={styles.flipY} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
disabled={i === rows.length - 1}
|
||||
onClick={() => editor.moveDown(i)}
|
||||
title="Ниже"
|
||||
>
|
||||
<Icon name="chevron-down" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||
onClick={() => editor.remove(i)}
|
||||
title="Удалить"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{editable && !loading && (
|
||||
<div className={styles.matrixPicker}>
|
||||
<UserPicker
|
||||
search={search}
|
||||
onPick={editor.addApprover}
|
||||
placeholder="Добавить согласующего: начните вводить ФИО…"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react';
|
||||
import styles from './approval.module.scss';
|
||||
import { ISiteUser } from '../models/ISiteUser';
|
||||
|
||||
export interface IUserPickerProps {
|
||||
search: (query: string) => Promise<ISiteUser[]>;
|
||||
onPick: (user: ISiteUser) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Лёгкий поиск пользователя сайта: ввод → выпадающий список → выбор. */
|
||||
export const UserPicker: React.FC<IUserPickerProps> = ({ search, onPick, placeholder }) => {
|
||||
const [query, setQuery] = React.useState<string>('');
|
||||
const [results, setResults] = React.useState<ISiteUser[]>([]);
|
||||
const [busy, setBusy] = React.useState<boolean>(false);
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
setQuery(value);
|
||||
if (value.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
search(value)
|
||||
.then((r) => {
|
||||
setResults(r);
|
||||
setBusy(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setResults([]);
|
||||
setBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePick = (user: ISiteUser): void => {
|
||||
onPick(user);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.picker}>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={query}
|
||||
placeholder={placeholder || 'Начните вводить ФИО…'}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
{busy && <div className={styles.pickerNote}>Поиск…</div>}
|
||||
{!busy && results.length > 0 && (
|
||||
<div className={styles.pickerList}>
|
||||
{results.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
className={styles.pickerItem}
|
||||
onClick={() => handlePick(u)}
|
||||
>
|
||||
{u.name}
|
||||
{u.email && <span className={styles.pickerEmail}>{u.email}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
// ---- Общий инпут (пикер) ----
|
||||
.input {
|
||||
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;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Пикер пользователя ----
|
||||
.picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pickerNote {
|
||||
margin-top: $sp-1;
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.pickerList {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: $sp-1;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
box-shadow: $sh-2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pickerItem {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.pickerEmail {
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
// ---- Матрица согласования ----
|
||||
.matrixWrap {
|
||||
margin-top: $sp-5;
|
||||
padding: $sp-4;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
background: $gray-50;
|
||||
}
|
||||
|
||||
.matrixTitle {
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-section-title;
|
||||
font-weight: 600;
|
||||
color: $navy-800;
|
||||
margin-bottom: $sp-3;
|
||||
}
|
||||
|
||||
.matrixNote {
|
||||
font-size: $fs-sm;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.matrix {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: $fs-sm;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: $gray-500;
|
||||
font-size: $fs-label;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid $gray-200;
|
||||
color: $gray-900;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.matrixActions {
|
||||
display: flex;
|
||||
gap: $sp-1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
color: $navy-700;
|
||||
cursor: pointer;
|
||||
transition: .16s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $gold-400;
|
||||
color: $gold-600;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: .4;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.iconBtnDanger {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $red-500;
|
||||
color: $red-500;
|
||||
}
|
||||
}
|
||||
|
||||
.flipY {
|
||||
transform: scaleY(-1);
|
||||
}
|
||||
|
||||
.matrixPicker {
|
||||
margin-top: $sp-3;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { IMatrixRow } from './models/IMatrixRow';
|
||||
import { ISiteUser } from './models/ISiteUser';
|
||||
|
||||
export interface IUseMatrixEditor {
|
||||
rows: IMatrixRow[];
|
||||
loading: boolean;
|
||||
error: string | undefined;
|
||||
editable: boolean;
|
||||
addApprover: (user: ISiteUser) => void;
|
||||
moveUp: (index: number) => void;
|
||||
moveDown: (index: number) => void;
|
||||
remove: (index: number) => void;
|
||||
}
|
||||
|
||||
function swap(rows: IMatrixRow[], a: number, b: number): IMatrixRow[] {
|
||||
const next = [...rows];
|
||||
[next[a], next[b]] = [next[b], next[a]];
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает матрицу согласования и (если editable) позволяет её редактировать.
|
||||
* @param load загрузчик строк (планируемый маршрут из resolve или строки заявки).
|
||||
* @param reloadKey при изменении — перезагрузка (например, email инициатора).
|
||||
* @param editable доступны ли правки (порядок/добавление/удаление).
|
||||
* @param ready можно ли грузить (false — ждём готовности данных).
|
||||
* @param getJobTitle должность добавляемого согласующего из профиля.
|
||||
*/
|
||||
export function useMatrixEditor(
|
||||
load: () => Promise<IMatrixRow[]>,
|
||||
reloadKey: string,
|
||||
editable: boolean,
|
||||
ready: boolean,
|
||||
getJobTitle: (loginName: string) => Promise<string>
|
||||
): IUseMatrixEditor {
|
||||
const [rows, setRows] = useState<IMatrixRow[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
load()
|
||||
.then((r) => {
|
||||
if (!active) return;
|
||||
setRows(r);
|
||||
setError(undefined);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!active) return;
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reloadKey, ready]);
|
||||
|
||||
const appendUser = (user: ISiteUser, position: string): void => {
|
||||
setRows((prev) => {
|
||||
if (user.email && prev.some((r) => r.email === user.email)) return prev;
|
||||
const row: IMatrixRow = {
|
||||
order: prev.length + 1,
|
||||
who: user.name,
|
||||
email: user.email,
|
||||
position,
|
||||
status: '—',
|
||||
date: '',
|
||||
comment: '',
|
||||
allowReassign: false,
|
||||
};
|
||||
return [...prev, row];
|
||||
});
|
||||
};
|
||||
|
||||
const addApprover = (user: ISiteUser): void => {
|
||||
// Должность подтягиваем из профиля; при неудаче добавляем без неё.
|
||||
getJobTitle(user.loginName)
|
||||
.then((position) => appendUser(user, position))
|
||||
.catch(() => appendUser(user, ''));
|
||||
};
|
||||
|
||||
const moveUp = (i: number): void => setRows((prev) => (i <= 0 ? prev : swap(prev, i, i - 1)));
|
||||
const moveDown = (i: number): void =>
|
||||
setRows((prev) => (i >= prev.length - 1 ? prev : swap(prev, i, i + 1)));
|
||||
const remove = (i: number): void => setRows((prev) => prev.filter((_, idx) => idx !== i));
|
||||
|
||||
return { rows, loading, error, editable, addApprover, moveUp, moveDown, remove };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Сайт SharePoint, где живут списки данных портала.
|
||||
* Портал (web part) может размещаться на другом сайте (например, на главной),
|
||||
* поэтому клиент PnPjs всегда нацеливаем на этот адрес явно.
|
||||
*
|
||||
* TODO: при необходимости вынести в свойство web part (property pane),
|
||||
* чтобы не хардкодить и переключать между dev/prod-сайтами.
|
||||
*/
|
||||
export const DATA_SITE_URL = 'https://tnsukz.sharepoint.com/sites/requests';
|
||||
|
||||
/**
|
||||
* Внешний approval-route API (маршруты согласования по ролям).
|
||||
* Авторизация — AadHttpClient (SSO текущего пользователя) по clientId
|
||||
* зарегистрированного AAD-приложения. Разрешение объявлено в
|
||||
* config/package-solution.json (webApiPermissionRequests) и одобряется
|
||||
* в Центре администрирования SharePoint (API access) при развёртывании.
|
||||
*/
|
||||
export const APPROVAL_API_CLIENT_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
||||
export const APPROVAL_API_BASE_URL = 'https://api.tnsu.kz/approval-route';
|
||||
@@ -0,0 +1,27 @@
|
||||
import { spfi, SPFI, SPFx } from '@pnp/sp';
|
||||
import '@pnp/sp/webs';
|
||||
import '@pnp/sp/lists';
|
||||
import '@pnp/sp/items';
|
||||
import '@pnp/sp/site-users/web';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { DATA_SITE_URL } from '../config';
|
||||
|
||||
// Единый инициализированный клиент PnPjs на весь web part.
|
||||
let _sp: SPFI | undefined;
|
||||
|
||||
/**
|
||||
* Инициализировать PnPjs контекстом web part. Вызывать один раз в onInit.
|
||||
* Клиент нацелен на сайт данных (DATA_SITE_URL), а не на текущий сайт, —
|
||||
* списки живут там, где бы ни стоял сам web part. Авторизация берётся из context.
|
||||
*/
|
||||
export function initSP(context: WebPartContext): void {
|
||||
_sp = spfi(DATA_SITE_URL).using(SPFx(context));
|
||||
}
|
||||
|
||||
/** Получить инициализированный клиент. Бросает, если initSP не был вызван. */
|
||||
export function getSP(): SPFI {
|
||||
if (!_sp) {
|
||||
throw new Error('PnPjs не инициализирован: вызовите initSP(context) в onInit web part.');
|
||||
}
|
||||
return _sp;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid;
|
||||
border-radius: $r-md;
|
||||
font-size: 12.5px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.warn {
|
||||
background: $gold-50;
|
||||
color: $amber-700;
|
||||
border-color: $gold-300;
|
||||
}
|
||||
|
||||
.ok {
|
||||
background: $green-50;
|
||||
color: $green-600;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.err {
|
||||
background: $red-50;
|
||||
color: $red-500;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Alert.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconName } from '../Icon/iconNames';
|
||||
|
||||
type AlertVariant = 'warn' | 'ok' | 'err';
|
||||
|
||||
interface IAlertProps {
|
||||
variant: AlertVariant;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantClass: Record<AlertVariant, string> = {
|
||||
warn: styles.warn,
|
||||
ok: styles.ok,
|
||||
err: styles.err
|
||||
};
|
||||
|
||||
const variantIcon: Record<AlertVariant, IconName> = {
|
||||
warn: 'warning',
|
||||
ok: 'check',
|
||||
err: 'close'
|
||||
};
|
||||
|
||||
export const Alert: React.FC<IAlertProps> = ({ variant, children }) => {
|
||||
const classes = [styles.alert, variantClass[variant]].join(' ');
|
||||
|
||||
return (
|
||||
<div className={classes} role="status">
|
||||
<Icon name={variantIcon[variant]} size="sm" />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 10px 20px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $r-sm;
|
||||
font-family: $font-sans;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: .18s;
|
||||
|
||||
&:disabled {
|
||||
opacity: .55;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sm {
|
||||
padding: 7px 14px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: $navy-800;
|
||||
border-color: $navy-800;
|
||||
color: #fff;
|
||||
|
||||
&:hover { background: $navy-700; box-shadow: $sh-2; }
|
||||
}
|
||||
|
||||
.accent {
|
||||
background: $grad-gold;
|
||||
border-color: $gold-500;
|
||||
color: $navy-950;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover { box-shadow: $sh-gold; transform: translateY(-1px); }
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: $white;
|
||||
border-color: $gray-300;
|
||||
color: $navy-800;
|
||||
|
||||
&:hover { border-color: $gold-500; color: $navy-900; }
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: $navy-700;
|
||||
|
||||
&:hover { background: $navy-100; }
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: $white;
|
||||
border-color: $gray-300;
|
||||
color: $red-500;
|
||||
|
||||
&:hover { border-color: $red-500; background: $red-50; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Button.module.scss';
|
||||
|
||||
type ButtonVariant = 'primary' | 'accent' | 'secondary' | 'ghost' | 'danger';
|
||||
type ButtonSize = 'md' | 'sm';
|
||||
|
||||
interface IButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const variantClass: Record<ButtonVariant, string> = {
|
||||
primary: styles.primary,
|
||||
accent: styles.accent,
|
||||
secondary: styles.secondary,
|
||||
ghost: styles.ghost,
|
||||
danger: styles.danger
|
||||
};
|
||||
|
||||
export const Button: React.FC<IButtonProps> = ({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
type = 'button',
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
const classes = [
|
||||
styles.btn,
|
||||
variantClass[variant],
|
||||
size === 'sm' ? styles.sm : '',
|
||||
fullWidth ? styles.full : '',
|
||||
className
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
// type по умолчанию 'button', чтобы кнопки внутри формы не сабмитили её случайно.
|
||||
return (
|
||||
<button type={type} className={classes} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.section {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 14px 0 10px;
|
||||
border: 0;
|
||||
border-top: 1px solid $gray-200;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 0;
|
||||
width: 48px;
|
||||
height: 1px;
|
||||
background: $gold-500;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: $font-serif;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
letter-spacing: .2px;
|
||||
|
||||
svg { color: $gold-600; }
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: flex;
|
||||
color: $gray-400;
|
||||
transition: transform .2s;
|
||||
}
|
||||
|
||||
.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px 22px;
|
||||
padding-top: 16px;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import styles from './CollapseSection.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconName } from '../Icon/iconNames';
|
||||
|
||||
interface ICollapseSectionProps {
|
||||
title: string;
|
||||
icon: IconName;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Сворачиваемая секция формы: заголовок с иконкой и шевроном + тело. */
|
||||
export const CollapseSection: React.FC<ICollapseSectionProps> = ({ title, icon, defaultOpen = true, children }) => {
|
||||
const [open, setOpen] = useState<boolean>(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<button type="button" className={styles.header} onClick={() => setOpen((prev) => !prev)}>
|
||||
<span className={styles.title}>
|
||||
<Icon name={icon} size="sm" />
|
||||
{title}
|
||||
</span>
|
||||
<span className={[styles.chevron, open ? styles.open : ''].filter(Boolean).join(' ')}>
|
||||
<Icon name="chevron-down" size="sm" />
|
||||
</span>
|
||||
</button>
|
||||
{open && <div className={styles.body}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin: 26px -32px -32px;
|
||||
padding: 16px 32px;
|
||||
background: $white;
|
||||
border-top: 1px solid $gray-200;
|
||||
border-radius: 0 0 $r-lg $r-lg;
|
||||
box-shadow: 0 -4px 16px rgba(15, 36, 64, .04);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import styles from './FormActions.module.scss';
|
||||
|
||||
interface IFormActionsProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Нижняя липкая панель действий формы («Отмена» / «Сохранить»). */
|
||||
export const FormActions: React.FC<IFormActionsProps> = ({ children }) => {
|
||||
return <div className={styles.actions}>{children}</div>;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.card {
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-lg;
|
||||
box-shadow: $sh-1;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: $font-serif;
|
||||
font-size: $fs-form-title;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: $gray-400;
|
||||
margin-top: 6px;
|
||||
letter-spacing: .3px;
|
||||
}
|
||||
|
||||
.hairline {
|
||||
@include hairline;
|
||||
margin: 6px 0 22px;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
import styles from './FormCard.module.scss';
|
||||
|
||||
interface IFormCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Белая карточка формы: заголовок (Lora) + золотой hairline + содержимое. */
|
||||
export const FormCard: React.FC<IFormCardProps> = ({ title, subtitle, children }) => {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
{subtitle && <div className={styles.subtitle}>{subtitle}</div>}
|
||||
<hr className={styles.hairline} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: $fs-label;
|
||||
font-weight: 500;
|
||||
color: $gray-700;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
|
||||
.req {
|
||||
color: $gold-600;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import styles from './FormField.module.scss';
|
||||
|
||||
interface IFormFieldProps {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
required?: boolean;
|
||||
/** Занять обе колонки сетки. */
|
||||
full?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Ячейка формы: подпись со звёздочкой для обязательных + контрол. */
|
||||
export const FormField: React.FC<IFormFieldProps> = ({ label, htmlFor, required, full, children }) => {
|
||||
const classes = [styles.field, full ? styles.full : ''].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<label className={styles.label} htmlFor={htmlFor}>
|
||||
{label}
|
||||
{required && <span className={styles.req}> *</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px 22px;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import styles from './FormGrid.module.scss';
|
||||
|
||||
interface IFormGridProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Сетка полей в 2 колонки. Поля-дети — FormField (full — на всю ширину). */
|
||||
export const FormGrid: React.FC<IFormGridProps> = ({ children }) => {
|
||||
return <div className={styles.grid}>{children}</div>;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.lg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Icon.module.scss';
|
||||
import { IconName } from './iconNames';
|
||||
|
||||
type IconSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface IIconProps {
|
||||
name: IconName;
|
||||
size?: IconSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClass: Record<IconSize, string> = {
|
||||
sm: styles.sm,
|
||||
md: '',
|
||||
lg: styles.lg
|
||||
};
|
||||
|
||||
/**
|
||||
* Иконка из общего спрайта. Спрайт (IconSprite) должен быть отрисован
|
||||
* один раз в корне портала, иначе ссылки #i-<name> ничего не покажут.
|
||||
*/
|
||||
export const Icon: React.FC<IIconProps> = ({ name, size = 'md', className }) => {
|
||||
const classes = [styles.icon, sizeClass[size], className].filter(Boolean).join(' ');
|
||||
return (
|
||||
<svg className={classes} aria-hidden="true" focusable="false">
|
||||
<use href={`#i-${name}`} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* Скрытый SVG-спрайт со всеми иконками портала.
|
||||
* Рендерится один раз в корне (RequestPortal). Компонент Icon ссылается
|
||||
* на символы по id `#i-<name>` (см. iconNames.ts).
|
||||
*/
|
||||
export const IconSprite: React.FC = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" style={{ display: 'none' }} aria-hidden="true">
|
||||
<symbol id="i-building" viewBox="0 0 24 24">
|
||||
<path d="M3 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16" />
|
||||
<path d="M13 21V9a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v12" />
|
||||
<path d="M3 21h18" />
|
||||
<rect x="7" y="7" width="2" height="2" rx=".5" fill="currentColor" stroke="none" />
|
||||
<rect x="7" y="12" width="2" height="2" rx=".5" fill="currentColor" stroke="none" />
|
||||
<rect x="16" y="11" width="2" height="2" rx=".5" fill="currentColor" stroke="none" />
|
||||
</symbol>
|
||||
<symbol id="i-chart" viewBox="0 0 24 24">
|
||||
<rect x="3" y="12" width="4" height="9" rx="1" />
|
||||
<rect x="10" y="7" width="4" height="14" rx="1" />
|
||||
<rect x="17" y="3" width="4" height="18" rx="1" />
|
||||
</symbol>
|
||||
<symbol id="i-monitor" viewBox="0 0 24 24">
|
||||
<rect x="2" y="4" width="20" height="13" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</symbol>
|
||||
<symbol id="i-calculator" viewBox="0 0 24 24">
|
||||
<rect x="4" y="2" width="16" height="20" rx="2" />
|
||||
<path d="M8 6h8M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M16 14h.01M8 18h4" />
|
||||
</symbol>
|
||||
<symbol id="i-send" viewBox="0 0 24 24">
|
||||
<path d="M22 2 11 13" />
|
||||
<path d="m22 2-7 20-4-9-9-4z" />
|
||||
</symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24">
|
||||
<path d="M12 3v12m0 0-4-4m4 4 4-4" />
|
||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
|
||||
</symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24">
|
||||
<path d="M12 15V3m0 0-4 4m4-4 4 4" />
|
||||
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
|
||||
</symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
</symbol>
|
||||
<symbol id="i-document" viewBox="0 0 24 24">
|
||||
<path d="M6 3h8l5 5v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" />
|
||||
<path d="M14 3v5h5" />
|
||||
</symbol>
|
||||
<symbol id="i-calendar" viewBox="0 0 24 24">
|
||||
<rect x="3" y="4" width="18" height="17" rx="2" />
|
||||
<path d="M3 9h18M8 2v4M16 2v4" />
|
||||
</symbol>
|
||||
<symbol id="i-clipboard" viewBox="0 0 24 24">
|
||||
<path d="M9 3h6v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z" />
|
||||
<rect x="5" y="5" width="14" height="16" rx="2" />
|
||||
<path d="M9 12h6M9 16h4" />
|
||||
</symbol>
|
||||
<symbol id="i-people" viewBox="0 0 24 24">
|
||||
<circle cx="9" cy="7" r="3" />
|
||||
<path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2" />
|
||||
<circle cx="17" cy="8" r="2.5" />
|
||||
<path d="M17 13.5a3.5 3.5 0 0 1 3.5 3.5v1" />
|
||||
</symbol>
|
||||
<symbol id="i-vehicle" viewBox="0 0 24 24">
|
||||
<path d="M5 11l1.5-4.5A2 2 0 0 1 8.4 5h7.2a2 2 0 0 1 1.9 1.5L19 11" />
|
||||
<rect x="3" y="11" width="18" height="7" rx="2" />
|
||||
<circle cx="7.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="16.5" cy="15.5" r="1.5" fill="currentColor" stroke="none" />
|
||||
</symbol>
|
||||
<symbol id="i-chat" viewBox="0 0 24 24">
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-5.46-1.84L3 21l1.84-3.54A9 9 0 1 1 21 12z" />
|
||||
</symbol>
|
||||
<symbol id="i-camera" viewBox="0 0 24 24">
|
||||
<path d="M3 8a2 2 0 0 1 2-2h2l1.5-2h7L17 6h2a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<circle cx="12" cy="13" r="4" />
|
||||
</symbol>
|
||||
<symbol id="i-image" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" stroke="none" />
|
||||
<path d="m21 15-5-5L5 21" />
|
||||
</symbol>
|
||||
<symbol id="i-edit" viewBox="0 0 24 24">
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z" />
|
||||
</symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24">
|
||||
<rect x="9" y="9" width="11" height="11" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="5" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="19" r="1.5" fill="currentColor" stroke="none" />
|
||||
</symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24">
|
||||
<path d="M4 7h16M10 11v6M14 11v6" />
|
||||
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12" />
|
||||
<path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
|
||||
</symbol>
|
||||
<symbol id="i-close" viewBox="0 0 24 24">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</symbol>
|
||||
<symbol id="i-chevron-right" viewBox="0 0 24 24">
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</symbol>
|
||||
<symbol id="i-chevron-left" viewBox="0 0 24 24">
|
||||
<path d="m15 6-6 6 6 6" />
|
||||
</symbol>
|
||||
<symbol id="i-arrow-left" viewBox="0 0 24 24">
|
||||
<path d="M19 12H5m0 0 5-5m-5 5 5 5" />
|
||||
</symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</symbol>
|
||||
<symbol id="i-warning" viewBox="0 0 24 24">
|
||||
<path d="M12 2 1 21h22z" strokeLinejoin="round" />
|
||||
<path d="M12 9v5M12 17h.01" />
|
||||
</symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24">
|
||||
<path d="M5 12l5 5L20 7" />
|
||||
</symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</symbol>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
// Единый набор имён иконок. Значение = id символа в спрайте (см. IconSprite).
|
||||
// Добавляя иконку: 1) добавь имя сюда, 2) добавь <symbol> в IconSprite.
|
||||
export const ICON_NAMES = [
|
||||
'building',
|
||||
'chart',
|
||||
'monitor',
|
||||
'calculator',
|
||||
'send',
|
||||
'download',
|
||||
'upload',
|
||||
'folder',
|
||||
'document',
|
||||
'calendar',
|
||||
'clipboard',
|
||||
'people',
|
||||
'vehicle',
|
||||
'chat',
|
||||
'camera',
|
||||
'image',
|
||||
'edit',
|
||||
'copy',
|
||||
'more',
|
||||
'trash',
|
||||
'close',
|
||||
'chevron-right',
|
||||
'chevron-down',
|
||||
'chevron-left',
|
||||
'arrow-left',
|
||||
'search',
|
||||
'warning',
|
||||
'check',
|
||||
'plus'
|
||||
] as const;
|
||||
|
||||
export type IconName = (typeof ICON_NAMES)[number];
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
||||
|
||||
interface IPeopleFieldProps {
|
||||
context: WebPartContext;
|
||||
/** Логин/e-mail выбранного сотрудника (хранится в модели формы). */
|
||||
value: string;
|
||||
onChange: (login: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Выбор ОДНОГО сотрудника. Обёртка над PeoplePicker из @pnp/spfx-controls-react. */
|
||||
export const PeopleField: React.FC<IPeopleFieldProps> = ({ context, value, onChange, placeholder }) => {
|
||||
const handleChange = (items: IPickedUser[]): void => {
|
||||
const first = items && items.length > 0 ? items[0] : undefined;
|
||||
onChange(first ? pickedLogin(first) : '');
|
||||
};
|
||||
|
||||
return (
|
||||
<PeoplePicker
|
||||
context={buildPickerContext(context)}
|
||||
personSelectionLimit={1}
|
||||
principalTypes={[PrincipalType.User]}
|
||||
defaultSelectedUsers={value ? [value] : []}
|
||||
resolveDelay={300}
|
||||
ensureUser
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
import { PeoplePicker, PrincipalType } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { buildPickerContext, pickedLogin, IPickedUser } from './pickerContext';
|
||||
|
||||
interface IPeopleFieldMultiProps {
|
||||
context: WebPartContext;
|
||||
/** Логины выбранных сотрудников. */
|
||||
value: string[];
|
||||
onChange: (logins: string[]) => void;
|
||||
placeholder?: string;
|
||||
/** Максимум выбираемых людей. */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Выбор НЕСКОЛЬКИХ сотрудников (для колонок «Пользователь или группа (несколько)»). */
|
||||
export const PeopleFieldMulti: React.FC<IPeopleFieldMultiProps> = ({
|
||||
context,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
limit = 10
|
||||
}) => {
|
||||
const handleChange = (items: IPickedUser[]): void => {
|
||||
const logins = (items || []).map(pickedLogin).filter((login) => login.length > 0);
|
||||
onChange(logins);
|
||||
};
|
||||
|
||||
return (
|
||||
<PeoplePicker
|
||||
context={buildPickerContext(context)}
|
||||
personSelectionLimit={limit}
|
||||
principalTypes={[PrincipalType.User]}
|
||||
defaultSelectedUsers={value}
|
||||
resolveDelay={300}
|
||||
ensureUser
|
||||
placeholder={placeholder}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { IPeoplePickerContext } from '@pnp/spfx-controls-react/lib/PeoplePicker';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
|
||||
/** Что возвращает picker в onChange — берём логин (надёжнее для ensureUser/предзаполнения). */
|
||||
export interface IPickedUser {
|
||||
loginName?: string;
|
||||
secondaryText?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Идентификатор выбранного пользователя для хранения в форме.
|
||||
* Берём e-mail (secondaryText): пикер надёжно резолвит его обратно в ФИО через
|
||||
* defaultSelectedUsers, и ensureUser(email) работает при записи. Клеймовый
|
||||
* loginName (i:0#.f|membership|…) в чип попадает как есть, без имени — поэтому он в конце.
|
||||
*/
|
||||
export function pickedLogin(user: IPickedUser): string {
|
||||
return user.secondaryText || user.loginName || user.id || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Контекст для PeoplePicker из WebPartContext.
|
||||
* as unknown: @pnp/spfx-controls-react тянет свою копию sp-http-msgraph,
|
||||
* из-за чего тип msGraphClientFactory формально несовместим (в рантайме объекты те же).
|
||||
*/
|
||||
export function buildPickerContext(context: WebPartContext): IPeoplePickerContext {
|
||||
return {
|
||||
absoluteUrl: context.pageContext.web.absoluteUrl,
|
||||
msGraphClientFactory: context.msGraphClientFactory,
|
||||
spHttpClient: context.spHttpClient
|
||||
} as unknown as IPeoplePickerContext;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.select {
|
||||
appearance: none;
|
||||
padding: 11px 36px 11px 13px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background-color: $white;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
transition: .18s;
|
||||
|
||||
@include input-focus-gold;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Select.module.scss';
|
||||
|
||||
export interface ISelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ISelectProps {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: ISelectOption[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Выпадающий список (SharePoint Choice). */
|
||||
export const Select: React.FC<ISelectProps> = ({ id, value, onChange, options, placeholder }) => {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
className={styles.select}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.textarea {
|
||||
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;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
transition: .18s;
|
||||
|
||||
@include input-focus-gold;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-400;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import styles from './TextArea.module.scss';
|
||||
|
||||
interface ITextAreaProps {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
/** Многострочный ввод (SharePoint Note). */
|
||||
export const TextArea: React.FC<ITextAreaProps> = ({ id, value, onChange, placeholder, rows = 3 }) => {
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
className={styles.textarea}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.input {
|
||||
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;
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-400;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import styles from './TextInput.module.scss';
|
||||
|
||||
type InputType = 'text' | 'number' | 'date';
|
||||
|
||||
interface ITextInputProps {
|
||||
id?: string;
|
||||
type?: InputType;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** Однострочный ввод. type='number'|'date' — для числовых полей и дат. */
|
||||
export const TextInput: React.FC<ITextInputProps> = ({ id, type = 'text', value, onChange, placeholder }) => {
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
className={styles.input}
|
||||
type={type}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
background: $gray-300;
|
||||
transition: background .18s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: $white;
|
||||
box-shadow: $sh-1;
|
||||
transition: transform .18s;
|
||||
}
|
||||
|
||||
.input:checked + .track {
|
||||
background: $gold-500;
|
||||
}
|
||||
|
||||
.input:checked + .track .thumb {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.input:focus-visible + .track {
|
||||
box-shadow: 0 0 0 3px rgba(212, 160, 30, .25);
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 13px;
|
||||
color: $gray-700;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Toggle.module.scss';
|
||||
|
||||
interface IToggleProps {
|
||||
id?: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Подпись справа от переключателя (да/нет-поля). */
|
||||
onLabel?: string;
|
||||
}
|
||||
|
||||
/** Переключатель да/нет (SharePoint Boolean). */
|
||||
export const Toggle: React.FC<IToggleProps> = ({ id, checked, onChange, onLabel }) => {
|
||||
return (
|
||||
<label className={styles.wrap}>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className={styles.input}
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className={styles.track}>
|
||||
<span className={styles.thumb} />
|
||||
</span>
|
||||
{onLabel && <span className={styles.text}>{onLabel}</span>}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
// Единая точка импорта примитивов формы.
|
||||
export { Button } from './Button/Button';
|
||||
export { Alert } from './Alert/Alert';
|
||||
export { FormCard } from './FormCard/FormCard';
|
||||
export { FormGrid } from './FormGrid/FormGrid';
|
||||
export { FormField } from './FormField/FormField';
|
||||
export { TextInput } from './TextInput/TextInput';
|
||||
export { TextArea } from './TextArea/TextArea';
|
||||
export { Select } from './Select/Select';
|
||||
export type { ISelectOption } from './Select/Select';
|
||||
export { Toggle } from './Toggle/Toggle';
|
||||
export { CollapseSection } from './CollapseSection/CollapseSection';
|
||||
export { FormActions } from './FormActions/FormActions';
|
||||
export { PeopleField } from './PeopleField/PeopleField';
|
||||
export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti';
|
||||
export { Icon } from './Icon/Icon';
|
||||
Reference in New Issue
Block a user