feat: changed get time logic to approvers
This commit is contained in:
@@ -2,3 +2,7 @@ export { getDepartments } from './departments.service';
|
||||
export type { IDepartment } from './models/IDepartment';
|
||||
export { DepartmentPicker } from './ui/DepartmentPicker';
|
||||
export type { IDepartmentPickerProps } from './ui/DepartmentPicker';
|
||||
export { getProjects } from './projects.service';
|
||||
export type { IProject } from './models/IProject';
|
||||
export { ProjectPicker } from './ui/ProjectPicker';
|
||||
export type { IProjectPickerProps } from './ui/ProjectPicker';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// GET https://api.tnsu.kz/Project (пагинация ?page&pageSize&includeDeleted) →
|
||||
// { items: IProject[] }. Ниже — только поля, которые реально используем в портале.
|
||||
// В ответе есть и другие (latitude/longitude, contractType, managerHSE, security,
|
||||
// longName, id1c и т.д.) — при необходимости добавить сюда.
|
||||
export interface IProject {
|
||||
/** Числовой Id проекта. */
|
||||
id: number;
|
||||
/** GUID проекта — стабильный идентификатор, храним его как SourceProjectId. */
|
||||
oid: string;
|
||||
/** Наименование проекта (то, что видит пользователь). */
|
||||
name: string;
|
||||
/** Код проекта (напр. «2026»). */
|
||||
code: string;
|
||||
/** Адрес / регион объекта. */
|
||||
address: string;
|
||||
/** Заказчик. */
|
||||
customerName: string;
|
||||
/** Руководитель проекта — email. Пригодится для маршрута согласования. */
|
||||
projectManager: string;
|
||||
/** Планировщик — email. */
|
||||
planner: string;
|
||||
/** Статус проекта (напр. «Текущий»). */
|
||||
status: string;
|
||||
/** Признак удалённого проекта. */
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
/** Обёртка ответа /Project. */
|
||||
export interface IProjectListResponse {
|
||||
items: IProject[];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config';
|
||||
import { IProject, IProjectListResponse } from './models/IProject';
|
||||
|
||||
// GET /Project — справочник проектов. Ответ обёрнут в { items: [...] } и
|
||||
// ПАГИНИРОВАН, поэтому тянем ВСЕ страницы (иначе проекты за пределами первой
|
||||
// страницы не находятся в поиске пикера). Авторизация та же (AadHttpClient +
|
||||
// APPROVAL_API_CLIENT_ID, SSO текущего пользователя).
|
||||
const PAGE_SIZE = 500;
|
||||
const MAX_PAGES = 50; // страховка от бесконечного цикла (до 25 000 проектов)
|
||||
|
||||
async function fetchProjects(context: WebPartContext): Promise<IProject[]> {
|
||||
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||
const all: IProject[] = [];
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const response: HttpClientResponse = await client.get(
|
||||
`${DICTIONARY_API_BASE_URL}/Project?page=${page}&pageSize=${PAGE_SIZE}&includeDeleted=false`,
|
||||
AadHttpClient.configurations.v1
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Project (page ${page}): ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const body: IProjectListResponse = await response.json();
|
||||
const items = body.items ?? [];
|
||||
all.push(...items);
|
||||
// Последняя страница — вернулось меньше запрошенного размера.
|
||||
if (items.length < PAGE_SIZE) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
|
||||
// промис повторным вызовам (как в departments.service).
|
||||
let cache: Promise<IProject[]> | undefined;
|
||||
export function getProjects(context: WebPartContext): Promise<IProject[]> {
|
||||
if (!cache) {
|
||||
cache = fetchProjects(context).catch((e) => {
|
||||
cache = undefined;
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
@@ -65,7 +65,13 @@ export const DepartmentPicker: React.FC<IDepartmentPickerProps> = ({
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={effectiveReadOnly}
|
||||
onChange={(e) => onChange(e.target.value, '')}
|
||||
// setOpen(true) и при вводе, а не только по фокусу: после выбора список
|
||||
// закрывается (handlePick), но фокус остаётся на поле — без этого
|
||||
// повторный ввод в том же фокусе не открывал бы выпадашку заново.
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value, '');
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
@import '../../../../../styles/design-tokens';
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
// справа место под крестик-очистку
|
||||
padding: 11px 36px 11px 13px;
|
||||
border: 1px solid $gray-300;
|
||||
border-radius: $r-sm;
|
||||
background: $white;
|
||||
font-family: $font-sans;
|
||||
font-size: 13.5px;
|
||||
color: $gray-900;
|
||||
transition: .18s;
|
||||
|
||||
@include input-focus-gold;
|
||||
@include readonly-field(':read-only');
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
.clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: $r-sm;
|
||||
background: transparent;
|
||||
color: $gray-400;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: .15s;
|
||||
|
||||
&:hover {
|
||||
background: $gray-100;
|
||||
color: $gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: $sp-1;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-sm;
|
||||
box-shadow: $sh-2;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-900;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: $navy-50;
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as React from 'react';
|
||||
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import styles from './ProjectPicker.module.scss';
|
||||
import { useFormReadOnly } from '../../ui/FormMode';
|
||||
import { getProjects } from '../projects.service';
|
||||
import { IProject } from '../models/IProject';
|
||||
|
||||
export interface IProjectPickerProps {
|
||||
context: WebPartContext;
|
||||
id?: string;
|
||||
/** Текущее название проекта (то, что видно в поле). */
|
||||
value: string;
|
||||
/**
|
||||
* name — введённый/выбранный текст; id — oid проекта ('' если текст введён
|
||||
* вручную и не выбран из списка); project — выбранная запись (для снимка
|
||||
* руководителя/планировщика на будущий маршрут). При ручном вводе project не передаётся.
|
||||
*/
|
||||
onChange: (name: string, id: string, project?: IProject) => void;
|
||||
placeholder?: string;
|
||||
/** Только чтение (режим согласующего). */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const MAX_SUGGESTIONS = 30;
|
||||
|
||||
/** Поле выбора проекта со справочником (GET /Project): ввод текста фильтрует список, выбор проставляет oid. */
|
||||
export const ProjectPicker: React.FC<IProjectPickerProps> = ({
|
||||
context,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
readOnly
|
||||
}) => {
|
||||
const contextReadOnly = useFormReadOnly();
|
||||
const effectiveReadOnly = readOnly ?? contextReadOnly;
|
||||
|
||||
const [projects, setProjects] = React.useState<IProject[]>([]);
|
||||
const [open, setOpen] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
getProjects(context)
|
||||
.then((list) => {
|
||||
if (alive) setProjects(list);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
const suggestions = React.useMemo(() => {
|
||||
const query = value.trim().toLowerCase();
|
||||
if (!query) return [];
|
||||
return projects
|
||||
.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().indexOf(query) !== -1 ||
|
||||
(p.code && p.code.toLowerCase().indexOf(query) !== -1)
|
||||
)
|
||||
.slice(0, MAX_SUGGESTIONS);
|
||||
}, [projects, value]);
|
||||
|
||||
const handlePick = (project: IProject): void => {
|
||||
onChange(project.name, project.oid, project);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<input
|
||||
id={id}
|
||||
className={styles.input}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={effectiveReadOnly}
|
||||
// setOpen(true) и при вводе, а не только по фокусу: после выбора список
|
||||
// закрывается (handlePick), но фокус остаётся на поле — без этого
|
||||
// повторный ввод в том же фокусе не открывал бы выпадашку заново.
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value, '');
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
/>
|
||||
{/* Быстрая очистка: сбрасываем и текст, и oid проекта. preventDefault на
|
||||
mousedown — чтобы клик не «съел» фокус до onClick (как в списке ниже). */}
|
||||
{!effectiveReadOnly && value.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clear}
|
||||
aria-label="Очистить"
|
||||
title="Очистить"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
onChange('', '');
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{!effectiveReadOnly && open && suggestions.length > 0 && (
|
||||
<div className={styles.list}>
|
||||
{suggestions.map((p) => (
|
||||
<button
|
||||
key={p.oid}
|
||||
type="button"
|
||||
className={styles.item}
|
||||
// preventDefault на mousedown — иначе input теряет фокус (onBlur) раньше onClick, и список закрывается до выбора.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handlePick(p)}
|
||||
>
|
||||
{p.name}
|
||||
<span className={styles.meta}>
|
||||
{[p.code, p.customerName].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user