feat: changed get time logic to approvers

This commit is contained in:
Тимур Городецкий
2026-08-13 10:01:08 +05:00
parent 2e6e138e8d
commit 8578653c16
55 changed files with 2249 additions and 46 deletions
@@ -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>
);
};