раздел «Отчёты» в боковом меню — скачивание отчёта по вакансиям #17
@@ -36,6 +36,8 @@ import { SearchView } from '../features/myRequests/SearchView';
|
|||||||
import { getNavCounts, INavCounts } from '../features/myRequests/myRequests.service';
|
import { getNavCounts, INavCounts } from '../features/myRequests/myRequests.service';
|
||||||
import { IMyRequest } from '../features/myRequests/models/IMyRequest';
|
import { IMyRequest } from '../features/myRequests/models/IMyRequest';
|
||||||
import { isEditableStatus } from '../shared/approval';
|
import { isEditableStatus } from '../shared/approval';
|
||||||
|
import { hasReportsAccess } from '../shared/reports/reportsAccess';
|
||||||
|
import { ReportsFeature } from '../features/reports/ReportsFeature';
|
||||||
|
|
||||||
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
// Оболочка портала: тёмное боковое меню (разделы) + основная область.
|
||||||
// Логики фич тут нет — файл общий, меняется редко.
|
// Логики фич тут нет — файл общий, меняется редко.
|
||||||
@@ -70,6 +72,22 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
const [mobileNavOpen, setMobileNavOpen] = React.useState<boolean>(false);
|
const [mobileNavOpen, setMobileNavOpen] = React.useState<boolean>(false);
|
||||||
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
|
const [navCounts, setNavCounts] = React.useState<INavCounts | undefined>(undefined);
|
||||||
|
|
||||||
|
// Раздел «Отчёты» — точечный список почт, пока в SharePoint нет группы HR
|
||||||
|
// (см. reportsAccess.ts). Пересчитывается на каждый рендер намеренно: сам
|
||||||
|
// hasReportsAccess — синхронная проверка без сети, а input (effectiveUserEmail)
|
||||||
|
// меняется только по подмене личности в property pane, что и так вызывает
|
||||||
|
// ререндер всей веб-части.
|
||||||
|
const canSeeReports = hasReportsAccess(context);
|
||||||
|
|
||||||
|
// Прямой переход по ссылке (?rpnav=reports) в обход бокового меню — тот же
|
||||||
|
// раздел, что скрыт из навигации, должен быть недоступен и так: без этого
|
||||||
|
// редиректа он открылся бы у любого, кто угадает или сохранит такую ссылку.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (route.nav === 'reports' && !canSeeReports) {
|
||||||
|
navigate({ mode: 'submit', nav: 'new' }, { replace: true });
|
||||||
|
}
|
||||||
|
}, [route.nav, canSeeReports]);
|
||||||
|
|
||||||
// Уникальный код заявки для заголовка Drawer (approve/edit) — сама форма
|
// Уникальный код заявки для заголовка Drawer (approve/edit) — сама форма
|
||||||
// узнаёт его только после загрузки (см. onCodeLoaded в ApproveFeature/
|
// узнаёт его только после загрузки (см. onCodeLoaded в ApproveFeature/
|
||||||
// EditFeature), поэтому не берём route.itemId (это RowID в SharePoint, а не
|
// EditFeature), поэтому не берём route.itemId (это RowID в SharePoint, а не
|
||||||
@@ -278,6 +296,8 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Разделы бокового меню для пакетного Sidebar (счётчики — из navCounts).
|
// Разделы бокового меню для пакетного Sidebar (счётчики — из navCounts).
|
||||||
|
// «Отчёты» — только у тех, кому разрешено (см. canSeeReports выше): скрыт из
|
||||||
|
// меню совсем, а не показан неактивным, — раздела для остальных как бы не существует.
|
||||||
const navItems: ISidebarItem[] = [
|
const navItems: ISidebarItem[] = [
|
||||||
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
|
{ id: 'new', title: 'Новая заявка', icon: 'plus' },
|
||||||
{ id: 'approvals', title: 'На согласовании', icon: 'user-check', count: navCounts?.approvals },
|
{ id: 'approvals', title: 'На согласовании', icon: 'user-check', count: navCounts?.approvals },
|
||||||
@@ -285,6 +305,7 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
{ id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework },
|
{ id: 'rework', title: 'Доработка', icon: 'edit', count: navCounts?.rework },
|
||||||
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
{ id: 'own', title: 'Мои заявки', icon: 'clipboard' },
|
||||||
{ id: 'search', title: 'Поиск', icon: 'search' },
|
{ id: 'search', title: 'Поиск', icon: 'search' },
|
||||||
|
...(canSeeReports ? [{ id: 'reports', title: 'Отчёты', icon: 'chart' } as ISidebarItem] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -375,6 +396,8 @@ const RequestPortal: React.FC<IRequestPortalProps> = ({ hasTeamsContext, context
|
|||||||
<OwnTabs activeId={route.ownTab ?? 'created'} onChange={gotoOwnTab} />
|
<OwnTabs activeId={route.ownTab ?? 'created'} onChange={gotoOwnTab} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{route.nav === 'reports' && canSeeReports && <ReportsFeature context={context} />}
|
||||||
|
|
||||||
{LIST_VIEWS[route.nav] !== undefined && (
|
{LIST_VIEWS[route.nav] !== undefined && (
|
||||||
<RequestsTableView
|
<RequestsTableView
|
||||||
context={context}
|
context={context}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@import '../../../../styles/design-tokens';
|
||||||
|
|
||||||
|
.view {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $sp-3;
|
||||||
|
margin-top: $sp-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: $sp-2;
|
||||||
|
font-size: $fs-sm;
|
||||||
|
color: $gray-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { Alert, Button, FormCard, FormField, FormGrid, Icon, TextInput } from '@tnsu/ui-kit';
|
||||||
|
import { downloadVacancyReport } from '../../shared/reports/reports.service';
|
||||||
|
import styles from './ReportsFeature.module.scss';
|
||||||
|
|
||||||
|
// Раздел «Отчёты» — пока один отчёт (по вакансиям), поэтому без списка
|
||||||
|
// карточек: сразу форма периода + кнопка. Появится второй отчёт — тут
|
||||||
|
// понадобится список/переключатель, но городить его заранее незачем.
|
||||||
|
|
||||||
|
function todayIso(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const mm = `${now.getMonth() + 1}`.padStart(2, '0');
|
||||||
|
const dd = `${now.getDate()}`.padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Первое число текущего месяца — разумный дефолт периода отчёта. */
|
||||||
|
function firstDayOfMonthIso(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const mm = `${now.getMonth() + 1}`.padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${mm}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReportsFeatureProps {
|
||||||
|
context: WebPartContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReportsFeature: React.FC<IReportsFeatureProps> = ({ context }) => {
|
||||||
|
const today = todayIso();
|
||||||
|
const [from, setFrom] = React.useState<string>(firstDayOfMonthIso());
|
||||||
|
const [to, setTo] = React.useState<string>(today);
|
||||||
|
const [loading, setLoading] = React.useState<boolean>(false);
|
||||||
|
const [error, setError] = React.useState<string>('');
|
||||||
|
|
||||||
|
const periodInvalid = !!from && !!to && from > to;
|
||||||
|
|
||||||
|
const download = (): void => {
|
||||||
|
if (!from || !to || periodInvalid) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
downloadVacancyReport(context, from, to)
|
||||||
|
.catch((e: Error) => setError(e.message || 'Не удалось сформировать отчёт'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.view}>
|
||||||
|
<FormCard title="Отчёт по вакансиям" subtitle="Заявки на подбор и на приём за выбранный период">
|
||||||
|
<FormGrid>
|
||||||
|
<FormField label="С" htmlFor="reportFrom" required>
|
||||||
|
<TextInput id="reportFrom" type="date" value={from} onChange={setFrom} max={to || undefined} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="По" htmlFor="reportTo" required>
|
||||||
|
<TextInput id="reportTo" type="date" value={to} onChange={setTo} min={from || undefined} max={today} />
|
||||||
|
</FormField>
|
||||||
|
</FormGrid>
|
||||||
|
|
||||||
|
{periodInvalid && <Alert variant="warn">Конец периода раньше его начала.</Alert>}
|
||||||
|
{error && <Alert variant="err">{error}</Alert>}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button
|
||||||
|
variant="accent"
|
||||||
|
onClick={download}
|
||||||
|
disabled={loading || !from || !to || periodInvalid}
|
||||||
|
>
|
||||||
|
<Icon name="download" size="sm" /> {loading ? 'Формируется…' : 'Скачать xlsx'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={styles.hint}>
|
||||||
|
Период отбирает заявки по дате подачи заявки на подбор. Заявки на приём в отчёт
|
||||||
|
попадают независимо от периода — по факту закрытия вакансии.
|
||||||
|
</p>
|
||||||
|
</FormCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -185,7 +185,7 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
(login) => approval.getJobTitle(login)
|
(login) => approval.getJobTitle(login)
|
||||||
);
|
);
|
||||||
|
|
||||||
const isValid = data.CandidateName.trim().length > 0 && data.Position.trim().length > 0;
|
const isValid = data.HireCandidateName.trim().length > 0 && data.Position.trim().length > 0;
|
||||||
const validate = (): string | undefined => {
|
const validate = (): string | undefined => {
|
||||||
if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.';
|
if (!isValid) return 'Заполните обязательные поля: Ф.И.О. кандидата, Должность.';
|
||||||
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`;
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ function parseJsonArray<T>(raw: unknown): T[] {
|
|||||||
|
|
||||||
// Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке.
|
// Скалярные поля (текст/выбор/число/дата) — читаем как есть, приводим к строке.
|
||||||
const SCALAR_FIELDS: (keyof IFormData)[] = [
|
const SCALAR_FIELDS: (keyof IFormData)[] = [
|
||||||
'Phone', 'HireType', 'ReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode',
|
'Phone', 'HireType', 'HireReplacedEmployee', 'Project', 'ProjectId', 'ProjectCode',
|
||||||
'CandidateName', 'HireDate', 'Position', 'WorkKind',
|
'HireCandidateName', 'HireHireDate', 'Position', 'WorkKind',
|
||||||
'Department', 'DepartmentId', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
|
'Department', 'DepartmentId', 'SiteLocation', 'PpeHeight', 'PpeClothingSize', 'PpeShoeSize', 'ProbationPeriod',
|
||||||
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
|
'SalaryProbation', 'SalaryAfterProbation', 'WorkSchedule', 'WorkScheduleOther',
|
||||||
'AgencyName', 'AgencyTerms',
|
'AgencyName', 'AgencyTerms',
|
||||||
@@ -58,7 +58,7 @@ export async function loadHireRequest(itemId: number, listId?: string): Promise<
|
|||||||
const value = item[key];
|
const value = item[key];
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
writable[key] = '';
|
writable[key] = '';
|
||||||
} else if (key === 'HireDate' && typeof value === 'string') {
|
} else if (key === 'HireHireDate' && typeof value === 'string') {
|
||||||
writable[key] = value.slice(0, 10); // ISO → YYYY-MM-DD для input type=date
|
writable[key] = value.slice(0, 10); // ISO → YYYY-MM-DD для input type=date
|
||||||
} else {
|
} else {
|
||||||
writable[key] = String(value);
|
writable[key] = String(value);
|
||||||
|
|||||||
@@ -18,7 +18,16 @@ export interface IFormData {
|
|||||||
Companies: ICompany[]; // мультивыбор компаний (справочник), хранится CompaniesJson
|
Companies: ICompany[]; // мультивыбор компаний (справочник), хранится CompaniesJson
|
||||||
Phone: string;
|
Phone: string;
|
||||||
HireType: string;
|
HireType: string;
|
||||||
ReplacedEmployee: string;
|
/**
|
||||||
|
* Префикс Hire — обход коллизии managed properties: этот internalName
|
||||||
|
* (без префикса) используется и в форме «Реферальная программа» —
|
||||||
|
* ДРУГИМ полем с ДРУГИМ Id. Search индексировал managed property только у
|
||||||
|
* одного из двух списков сайта (см. sp-write props/field в tools; тот же
|
||||||
|
* приём, что VacancyOpenReason в RecruitingSelection). CandidateName и
|
||||||
|
* HireDate ниже переименованы по той же причине; ReferralForm НЕ трогать —
|
||||||
|
* там имя осталось прежним.
|
||||||
|
*/
|
||||||
|
HireReplacedEmployee: string;
|
||||||
// Проект/объект (справочник /Project) — справочное поле, на маршрут
|
// Проект/объект (справочник /Project) — справочное поле, на маршрут
|
||||||
// согласования НЕ влияет (в отличие от RecruitingSelection/TmcTransfer).
|
// согласования НЕ влияет (в отличие от RecruitingSelection/TmcTransfer).
|
||||||
Project: string;
|
Project: string;
|
||||||
@@ -30,8 +39,8 @@ export interface IFormData {
|
|||||||
SelectionRequestListId: string; // url партиции заявки на подбор (→ rplist)
|
SelectionRequestListId: string; // url партиции заявки на подбор (→ rplist)
|
||||||
SelectionRequestCode: string; // уникальный код для подписи/ссылки
|
SelectionRequestCode: string; // уникальный код для подписи/ссылки
|
||||||
// Кандидат
|
// Кандидат
|
||||||
CandidateName: string;
|
HireCandidateName: string;
|
||||||
HireDate: string;
|
HireHireDate: string;
|
||||||
Position: string;
|
Position: string;
|
||||||
WorkKind: string;
|
WorkKind: string;
|
||||||
/**
|
/**
|
||||||
@@ -75,15 +84,15 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Companies: [],
|
Companies: [],
|
||||||
Phone: '',
|
Phone: '',
|
||||||
HireType: '',
|
HireType: '',
|
||||||
ReplacedEmployee: '',
|
HireReplacedEmployee: '',
|
||||||
Project: '',
|
Project: '',
|
||||||
ProjectId: '',
|
ProjectId: '',
|
||||||
ProjectCode: '',
|
ProjectCode: '',
|
||||||
SelectionRequestId: '',
|
SelectionRequestId: '',
|
||||||
SelectionRequestListId: '',
|
SelectionRequestListId: '',
|
||||||
SelectionRequestCode: '',
|
SelectionRequestCode: '',
|
||||||
CandidateName: '',
|
HireCandidateName: '',
|
||||||
HireDate: '',
|
HireHireDate: '',
|
||||||
Position: '',
|
Position: '',
|
||||||
WorkKind: '',
|
WorkKind: '',
|
||||||
Department: '',
|
Department: '',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
|
{ "internalName": "Phone", "title": "Телефон", "type": "Text" },
|
||||||
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] },
|
{ "internalName": "HireType", "title": "Тип приёма", "type": "Choice", "choices": ["Новый сотрудник", "Вместо уволенного"] },
|
||||||
{ "internalName": "ReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" },
|
{ "internalName": "HireReplacedEmployee", "title": "Взамен кого (Ф.И.О. уволенного)", "type": "Text" },
|
||||||
|
|
||||||
{ "internalName": "Project", "title": "Проект", "type": "Text" },
|
{ "internalName": "Project", "title": "Проект", "type": "Text" },
|
||||||
{ "internalName": "ProjectId", "title": "Проект — Id", "type": "Text" },
|
{ "internalName": "ProjectId", "title": "Проект — Id", "type": "Text" },
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
{ "internalName": "SelectionRequestListId", "title": "Заявка на подбор — партиция (listId)", "type": "Text" },
|
{ "internalName": "SelectionRequestListId", "title": "Заявка на подбор — партиция (listId)", "type": "Text" },
|
||||||
{ "internalName": "SelectionRequestCode", "title": "Заявка на подбор — код", "type": "Text" },
|
{ "internalName": "SelectionRequestCode", "title": "Заявка на подбор — код", "type": "Text" },
|
||||||
|
|
||||||
{ "internalName": "CandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true },
|
{ "internalName": "HireCandidateName", "title": "Ф.И.О. кандидата", "type": "Text", "required": true },
|
||||||
{ "internalName": "HireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "HireHireDate", "title": "Дата приёма", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
{ "internalName": "Position", "title": "Должность", "type": "Text", "required": true },
|
||||||
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
{ "internalName": "WorkKind", "title": "Вид работ", "type": "Text" },
|
||||||
{ "internalName": "SiteName", "title": "Отдел / участок (устарело)", "type": "Text" },
|
{ "internalName": "SiteName", "title": "Отдел / участок (устарело)", "type": "Text" },
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
const handleHireTypeChange = (value: string): void => {
|
const handleHireTypeChange = (value: string): void => {
|
||||||
set('HireType', value);
|
set('HireType', value);
|
||||||
if (value !== HIRE_TYPE_REPLACEMENT) {
|
if (value !== HIRE_TYPE_REPLACEMENT) {
|
||||||
set('ReplacedEmployee', ''); // при не-замене очищаем скрытое поле
|
set('HireReplacedEmployee', ''); // при не-замене очищаем скрытое поле
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,11 +54,11 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{isReplacement && (
|
{isReplacement && (
|
||||||
<FormField label="Взамен кого (Ф.И.О. уволенного)" htmlFor="ReplacedEmployee" full>
|
<FormField label="Взамен кого (Ф.И.О. уволенного)" htmlFor="HireReplacedEmployee" full>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="ReplacedEmployee"
|
id="HireReplacedEmployee"
|
||||||
value={data.ReplacedEmployee}
|
value={data.HireReplacedEmployee}
|
||||||
onChange={(v) => set('ReplacedEmployee', v)}
|
onChange={(v) => set('HireReplacedEmployee', v)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { ISectionProps } from '../model';
|
|||||||
|
|
||||||
export const CandidateSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
export const CandidateSection: React.FC<ISectionProps> = ({ data, set, context }) => (
|
||||||
<CollapseSection title="Кандидат" icon="people">
|
<CollapseSection title="Кандидат" icon="people">
|
||||||
<FormField label="Ф.И.О. кандидата" htmlFor="CandidateName" required full>
|
<FormField label="Ф.И.О. кандидата" htmlFor="HireCandidateName" required full>
|
||||||
<TextInput id="CandidateName" value={data.CandidateName} onChange={(v) => set('CandidateName', v)} />
|
<TextInput id="HireCandidateName" value={data.HireCandidateName} onChange={(v) => set('HireCandidateName', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Дата приёма" htmlFor="HireDate">
|
<FormField label="Дата приёма" htmlFor="HireHireDate">
|
||||||
<TextInput id="HireDate" type="date" value={data.HireDate} onChange={(v) => set('HireDate', v)} />
|
<TextInput id="HireHireDate" type="date" value={data.HireHireDate} onChange={(v) => set('HireHireDate', v)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Должность" htmlFor="Position" required>
|
<FormField label="Должность" htmlFor="Position" required>
|
||||||
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
<TextInput id="Position" value={data.Position} onChange={(v) => set('Position', v)} />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function hireApprovalService(listId?: string): ApprovalListService {
|
|||||||
|
|
||||||
/** Осмысленный заголовок элемента списка (системная колонка Title). */
|
/** Осмысленный заголовок элемента списка (системная колонка Title). */
|
||||||
function buildTitle(data: IFormData): string {
|
function buildTitle(data: IFormData): string {
|
||||||
return ['Прием', data.CandidateName, data.Position].filter(Boolean).join(' — ');
|
return ['Прием', data.HireCandidateName, data.Position].filter(Boolean).join(' — ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */
|
/** Поля элемента списка данных из модели формы (текст/выбор/даты/числа). */
|
||||||
|
|||||||
@@ -18,8 +18,15 @@ export interface IFormData {
|
|||||||
Responsible: string;
|
Responsible: string;
|
||||||
SubmitDate: string;
|
SubmitDate: string;
|
||||||
ClosingDeadline: string;
|
ClosingDeadline: string;
|
||||||
OpenReason: string;
|
/**
|
||||||
OpenReasonDetails: string;
|
* Префикс Vacancy — не для читаемости, а обход коллизии managed properties:
|
||||||
|
* колонка называлась просто OpenReason, и Search индексировал её только у
|
||||||
|
* ОДНОГО из списков сайта с таким internal name (см. sp-write props/field в
|
||||||
|
* tools). Переименовали и перенесли данные скриптом (2026-08-29) — трогать
|
||||||
|
* имя обратно нельзя, коллизия вернётся.
|
||||||
|
*/
|
||||||
|
VacancyOpenReason: string;
|
||||||
|
VacancyOpenReasonDetails: string;
|
||||||
Companies: ICompany[];
|
Companies: ICompany[];
|
||||||
ObjectProject: string;
|
ObjectProject: string;
|
||||||
ObjectProjectId: string;
|
ObjectProjectId: string;
|
||||||
@@ -92,8 +99,8 @@ export const EMPTY_DATA: IFormData = {
|
|||||||
Responsible: '',
|
Responsible: '',
|
||||||
SubmitDate: '',
|
SubmitDate: '',
|
||||||
ClosingDeadline: '',
|
ClosingDeadline: '',
|
||||||
OpenReason: '',
|
VacancyOpenReason: '',
|
||||||
OpenReasonDetails: '',
|
VacancyOpenReasonDetails: '',
|
||||||
Companies: [],
|
Companies: [],
|
||||||
ObjectProject: '',
|
ObjectProject: '',
|
||||||
ObjectProjectId: '',
|
ObjectProjectId: '',
|
||||||
@@ -149,7 +156,7 @@ export const OPEN_REASON_REPLACE = 'Замена уволенного сотру
|
|||||||
export const OPEN_REASON_TRANSFER = 'Перевод сотрудника';
|
export const OPEN_REASON_TRANSFER = 'Перевод сотрудника';
|
||||||
export const OPEN_REASON_NEW = 'Новая штатная единица';
|
export const OPEN_REASON_NEW = 'Новая штатная единица';
|
||||||
|
|
||||||
/** Варианты «Причина открытия вакансии» — совпадают с choices колонки OpenReason. */
|
/** Варианты «Причина открытия вакансии» — совпадают с choices колонки VacancyOpenReason. */
|
||||||
export const OPEN_REASON_OPTIONS: ISelectOption[] = [
|
export const OPEN_REASON_OPTIONS: ISelectOption[] = [
|
||||||
{ value: OPEN_REASON_REPLACE, label: OPEN_REASON_REPLACE },
|
{ value: OPEN_REASON_REPLACE, label: OPEN_REASON_REPLACE },
|
||||||
{ value: OPEN_REASON_TRANSFER, label: OPEN_REASON_TRANSFER },
|
{ value: OPEN_REASON_TRANSFER, label: OPEN_REASON_TRANSFER },
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
{ "internalName": "RequestStatus", "title": "Статус", "type": "Choice", "choices": ["Черновик", "На согласовании", "Согласовано", "Отклонено", "Возвращена на доработку", "Выполнение", "Закрыта"] },
|
||||||
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
{ "internalName": "Responsible", "title": "Ответственный за подачу заявки", "type": "User", "userSelectionMode": "PeopleOnly", "required": true },
|
||||||
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
|
{ "internalName": "SubmitDate", "title": "Дата подачи заявки", "type": "DateTime", "dateOnly": true },
|
||||||
{ "internalName": "OpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Другое"] },
|
{ "internalName": "VacancyOpenReason", "title": "Причина открытия вакансии", "type": "Choice", "choices": ["Замена уволенного сотрудника", "Перевод сотрудника", "Новая штатная единица", "Другое"] },
|
||||||
{ "internalName": "OpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" },
|
{ "internalName": "VacancyOpenReasonDetails", "title": "Причина открытия — ФИО / уточнение", "type": "Text" },
|
||||||
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
|
{ "internalName": "CompaniesJson", "title": "Компании (JSON)", "type": "Note" },
|
||||||
{ "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" },
|
{ "internalName": "ObjectProject", "title": "Объект/Проект", "type": "Text" },
|
||||||
{ "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" },
|
{ "internalName": "ObjectProjectId", "title": "Объект/Проект — Id", "type": "Text" },
|
||||||
|
|||||||
+10
-10
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '../model';
|
} from '../model';
|
||||||
|
|
||||||
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context, onResponsibleChange }) => {
|
export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context, onResponsibleChange }) => {
|
||||||
const reason = data.OpenReason;
|
const reason = data.VacancyOpenReason;
|
||||||
const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
|
const showReasonDetails = reason !== '' && reason !== OPEN_REASON_NEW;
|
||||||
const reasonDetailsLabel =
|
const reasonDetailsLabel =
|
||||||
reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER ? 'ФИО сотрудника' : 'Уточните причину';
|
reason === OPEN_REASON_REPLACE || reason === OPEN_REASON_TRANSFER ? 'ФИО сотрудника' : 'Уточните причину';
|
||||||
@@ -45,24 +45,24 @@ export const ApplicationSection: React.FC<ISectionProps> = ({ data, set, context
|
|||||||
onChange={(v) => set('ClosingDeadline', v)}
|
onChange={(v) => set('ClosingDeadline', v)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="1.5 Причина открытия вакансии" htmlFor="OpenReason">
|
<FormField label="1.5 Причина открытия вакансии" htmlFor="VacancyOpenReason">
|
||||||
<Select
|
<Select
|
||||||
id="OpenReason"
|
id="VacancyOpenReason"
|
||||||
value={data.OpenReason}
|
value={data.VacancyOpenReason}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
set('OpenReason', v);
|
set('VacancyOpenReason', v);
|
||||||
if (v === '' || v === OPEN_REASON_NEW) set('OpenReasonDetails', '');
|
if (v === '' || v === OPEN_REASON_NEW) set('VacancyOpenReasonDetails', '');
|
||||||
}}
|
}}
|
||||||
options={OPEN_REASON_OPTIONS}
|
options={OPEN_REASON_OPTIONS}
|
||||||
placeholder="— выберите —"
|
placeholder="— выберите —"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
{showReasonDetails && (
|
{showReasonDetails && (
|
||||||
<FormField label={reasonDetailsLabel} htmlFor="OpenReasonDetails">
|
<FormField label={reasonDetailsLabel} htmlFor="VacancyOpenReasonDetails">
|
||||||
<TextInput
|
<TextInput
|
||||||
id="OpenReasonDetails"
|
id="VacancyOpenReasonDetails"
|
||||||
value={data.OpenReasonDetails}
|
value={data.VacancyOpenReasonDetails}
|
||||||
onChange={(v) => set('OpenReasonDetails', v)}
|
onChange={(v) => set('VacancyOpenReasonDetails', v)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ export async function loadPrefillFromHire(hireId: number): Promise<IFormData> {
|
|||||||
const { data: hire } = await loadHireRequest(hireId);
|
const { data: hire } = await loadHireRequest(hireId);
|
||||||
return {
|
return {
|
||||||
...EMPTY_DATA,
|
...EMPTY_DATA,
|
||||||
FullName: hire.CandidateName,
|
FullName: hire.HireCandidateName,
|
||||||
Phone: hire.Phone,
|
Phone: hire.Phone,
|
||||||
Position: hire.Position,
|
Position: hire.Position,
|
||||||
Department: hire.Department,
|
Department: hire.Department,
|
||||||
WorkKind: hire.WorkKind,
|
WorkKind: hire.WorkKind,
|
||||||
Location: hire.SiteLocation,
|
Location: hire.SiteLocation,
|
||||||
StartDate: hire.HireDate
|
StartDate: hire.HireHireDate
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
|
||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { APPROVAL_API_CLIENT_ID, getApprovalApiBaseUrl } from '../config';
|
||||||
|
|
||||||
|
// Обращения к api/reports approval-route API — по тому же принципу, что и
|
||||||
|
// approvalRoute.service.ts (AadHttpClient, SSO текущего пользователя), но
|
||||||
|
// ответ — не JSON, а сам файл (см. ReportsController.Vacancies).
|
||||||
|
|
||||||
|
/** Отчёт по вакансиям за период: даты YYYY-MM-DD (как в TextInput type="date"). */
|
||||||
|
export async function downloadVacancyReport(
|
||||||
|
context: WebPartContext,
|
||||||
|
from: string,
|
||||||
|
to: string
|
||||||
|
): Promise<void> {
|
||||||
|
const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID);
|
||||||
|
const url = `${getApprovalApiBaseUrl()}/api/reports/vacancies?from=${from}&to=${to}`;
|
||||||
|
const response: HttpClientResponse = await client.get(url, AadHttpClient.configurations.v1);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await extractErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
// Имя файла собираем сами, а не читаем Content-Disposition: approval-route
|
||||||
|
// не объявляет его в Access-Control-Expose-Headers (см. Program.cs, CORS),
|
||||||
|
// и без этого браузер отдал бы fetch-у пустой заголовок. Формат — тот же,
|
||||||
|
// что и в VacancyReportWorkbook.FileName на бэкенде.
|
||||||
|
saveBlob(blob, vacancyReportFileName(from, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Отчет по вакансиям dd.MM.yyyy-dd.MM.yyyy.xlsx» — как в VacancyReportWorkbook.FileName. */
|
||||||
|
function vacancyReportFileName(from: string, to: string): string {
|
||||||
|
return `Отчет по вакансиям ${formatRu(from)}-${formatRu(to)}.xlsx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRu(isoDate: string): string {
|
||||||
|
const [year, month, day] = isoDate.split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 400 от ReportsController приходит как { errors: string[] } (см.
|
||||||
|
* VacancyReportService.GetAsync — например «конец периода раньше начала»);
|
||||||
|
* остальные статусы — обычный текст/пусто.
|
||||||
|
*/
|
||||||
|
async function extractErrorMessage(response: HttpClientResponse): Promise<string> {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
try {
|
||||||
|
const parsed: { errors?: string[] } = JSON.parse(text);
|
||||||
|
if (parsed.errors && parsed.errors.length > 0) return parsed.errors.join('; ');
|
||||||
|
} catch {
|
||||||
|
// Не JSON — используем как есть.
|
||||||
|
}
|
||||||
|
return text || `${response.status} ${response.statusText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отдать Blob пользователю как скачивание файла — через временную ссылку. */
|
||||||
|
function saveBlob(blob: Blob, fileName: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
// Отложенный revoke — на некоторых браузерах немедленный обрывает скачивание,
|
||||||
|
// начатое кликом синхронно перед этим.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
|
import { effectiveUserEmail } from '../impersonation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Кто видит раздел «Отчёты» в боковом меню. Пока это фиксированный список
|
||||||
|
* почт — группы HR в SharePoint ещё нет (её заведут отдельно); появится —
|
||||||
|
* список ниже заменится на реальную проверку членства (см. TODO).
|
||||||
|
*
|
||||||
|
* Email — из справочника сотрудников (api.tnsu.kz/Employee/All), а не из
|
||||||
|
* Title сайта: у части учёток встречаются устаревшие site user записи со
|
||||||
|
* старым email (пример — Тришина: m.trishina@tnsu.kz как устаревший алиас
|
||||||
|
* рядом с текущим trishina.mariya@tnsu.kz).
|
||||||
|
*/
|
||||||
|
const ALLOWED_EMAILS: ReadonlySet<string> = new Set([
|
||||||
|
'trishina.mariya@tnsu.kz', // Мария Тришина
|
||||||
|
'gorodetskiy.timur@tnsu.kz', // Тимур Городецкий
|
||||||
|
'gorbenko.pavel@tnsu.kz', // Павел Горбенко
|
||||||
|
]);
|
||||||
|
|
||||||
|
// TODO: заменить точечный список на членство в SharePoint-группе HR, когда
|
||||||
|
// её заведут — тогда список выше станет фолбэком для тех троих, кто должен
|
||||||
|
// видеть раздел независимо от состава группы (администраторы портала).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Есть ли у текущего пользователя доступ к разделу «Отчёты». Использует
|
||||||
|
* effectiveUserEmail — то есть учитывает отладочную подмену личности (см.
|
||||||
|
* impersonation.ts): под подменённым пользователем раздел виден/не виден
|
||||||
|
* так же, как было бы у него самого.
|
||||||
|
*/
|
||||||
|
export function hasReportsAccess(context: WebPartContext): boolean {
|
||||||
|
const email = effectiveUserEmail(context).trim().toLowerCase();
|
||||||
|
return email.length > 0 && ALLOWED_EMAILS.has(email);
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ export type PortalMode = 'submit' | 'approve' | 'create' | 'edit';
|
|||||||
* «Возвращена на доработку»); в отличие от первых двух смотрит
|
* «Возвращена на доработку»); в отличие от первых двух смотрит
|
||||||
* на список данных по автору, а не на строки маршрута.
|
* на список данных по автору, а не на строки маршрута.
|
||||||
*/
|
*/
|
||||||
export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search';
|
export type NavSection = 'new' | 'approvals' | 'execution' | 'rework' | 'own' | 'search' | 'reports';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор
|
* Подвкладка раздела «Мои заявки». 'created' — заявки, где пользователь автор
|
||||||
@@ -259,6 +259,7 @@ function readStoredNav(): NavSection {
|
|||||||
raw === 'rework' ||
|
raw === 'rework' ||
|
||||||
raw === 'own' ||
|
raw === 'own' ||
|
||||||
raw === 'search' ||
|
raw === 'search' ||
|
||||||
|
raw === 'reports' ||
|
||||||
raw === 'new'
|
raw === 'new'
|
||||||
? raw
|
? raw
|
||||||
: 'new';
|
: 'new';
|
||||||
@@ -283,6 +284,7 @@ function parseNav(raw: string | undefined): NavSection {
|
|||||||
raw === 'rework' ||
|
raw === 'rework' ||
|
||||||
raw === 'own' ||
|
raw === 'own' ||
|
||||||
raw === 'search' ||
|
raw === 'search' ||
|
||||||
|
raw === 'reports' ||
|
||||||
raw === 'new'
|
raw === 'new'
|
||||||
) {
|
) {
|
||||||
return raw;
|
return raw;
|
||||||
|
|||||||
+56
-4
@@ -13,10 +13,14 @@
|
|||||||
пользователь вводит его в браузере сам, дальше CLI for Microsoft 365 держит токен у
|
пользователь вводит его в браузере сам, дальше CLI for Microsoft 365 держит токен у
|
||||||
себя. Ни пароля, ни client secret в репозитории и в командах нет.
|
себя. Ни пароля, ни client secret в репозитории и в командах нет.
|
||||||
|
|
||||||
**Работаем от имени пользователя, а не приложения.** Это не удобство, а требование:
|
**Работаем от имени пользователя, а не приложения.** Главное, что это даёт: видно
|
||||||
SharePoint Search REST (`/_api/search/query`) под app-only-токеном обычно не отдаёт
|
ровно то же, что видит пользователь в портале, — а у заявок портала это принципиально,
|
||||||
ничего, ему нужен пользовательский контекст. А Search — главное, что здесь измеряют.
|
потому что права разорваны НА КАЖДОМ элементе (см. «Грабли» ниже).
|
||||||
Побочный плюс: видно ровно то же, что видит пользователь в портале.
|
|
||||||
|
Раньше здесь было написано, что Search под app-only-токеном «обычно не отдаёт ничего».
|
||||||
|
Проверено 2026-08-28 на сертификате `CN=PnP-SharePoint-Sync` (тот же, что у бэкенда
|
||||||
|
`tnsu-approval-route`): **отдаёт** — и `postquery`, и кастомные managed properties.
|
||||||
|
Так что app-only Search как таковой рабочий; ограничения у него другие, см. «Грабли».
|
||||||
|
|
||||||
## Разовая подготовка
|
## Разовая подготовка
|
||||||
|
|
||||||
@@ -96,3 +100,51 @@ claims-логине частей будет четыре. Разбирать с
|
|||||||
партиций и в query-строке упирается в предел длины URL. Форма тела взята один в один
|
партиций и в query-строке упирается в предел длины URL. Форма тела взята один в один
|
||||||
из PnPjs (`@pnp/sp/search/query.js`): `{ request: { ...query } }`, массивы плоские,
|
из PnPjs (`@pnp/sp/search/query.js`): `{ request: { ...query } }`, массивы плоские,
|
||||||
`odata=nometadata`. Иначе инструмент мерил бы не то, что портал.
|
`odata=nometadata`. Иначе инструмент мерил бы не то, что портал.
|
||||||
|
|
||||||
|
**Часть полей индекс не отдаёт ВООБЩЕ, хотя значения в списках есть** (проверено
|
||||||
|
2026-08-28 поштучным сравнением Search и REST на одних и тех же элементах):
|
||||||
|
|
||||||
|
| Список | Поле | Пробовали имена |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Заявка на подбор | `OpenReason`, `OpenReasonDetails` | `…OWSCHCS`, `…OWSMTXT`, `…OWSTEXT`, с префиксом `TansuSite` |
|
||||||
|
| Прием на работу | `CandidateName`, `HireDate`, `ReplacedEmployee` | `…OWSTEXT`, `…OWSDATE`, с префиксом `TansuSite` |
|
||||||
|
| — согласование | `Ticket` (Lookup) | `TicketOWSTEXT`, `TicketOWSLKID`, `TicketId`, `Ticket` |
|
||||||
|
|
||||||
|
Отличить «managed property нет вовсе» от «есть, но у строк пусто» через
|
||||||
|
`SelectProperties` НЕЛЬЗЯ — на несуществующее имя ответ молча приходит с `null`.
|
||||||
|
**И `SortList` для этого не годится тоже**, хотя соблазнительно: `Sortable` — флаг,
|
||||||
|
независимый от `Retrievable`, и у автосозданных `ows_*` свойств он обычно выключен.
|
||||||
|
`SortList` падает 400/500 и на заведомо живом `PositionOWSTEXT`, то есть меряет не то.
|
||||||
|
Рабочий способ — сверять Search с REST на одних и тех же элементах, это делает
|
||||||
|
`sp-write props` (репозиторий `C:\work\git\tools`).
|
||||||
|
|
||||||
|
**Корневая причина (нашлась 2026-08-28): одноимённые колонки в разных списках — это
|
||||||
|
РАЗНЫЕ поля.** У каждого свой GUID, значит и своё crawled property (`ows_Position`,
|
||||||
|
`ows_Position1`, …), а автосозданное managed property привязано только к одному из них.
|
||||||
|
Отсюда картина, которая иначе выглядит бессмыслицей:
|
||||||
|
|
||||||
|
| Managed property | «Заявка на подбор 1 — данные» | «Прием на работу 1 — данные» |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PositionOWSTEXT` | отдаёт | **молчит** |
|
||||||
|
| `CandidateNameOWSTEXT` | (колонки нет) | **молчит**, но свойство рабочее в других списках |
|
||||||
|
| `RequestStatusOWSCHCS` | отдаёт | отдаёт |
|
||||||
|
|
||||||
|
То есть проверять надо не «работает ли свойство», а «работает ли оно **для этого
|
||||||
|
списка**». `sp-write props` в конце как раз и говорит, рабочее ли свойство где-то ещё
|
||||||
|
на сайте: если да — колонка этого списка отдельное поле, и в Search Schema нужно
|
||||||
|
домапить её crawled property к тому же managed property; если нигде — свойства нет и
|
||||||
|
его надо заводить.
|
||||||
|
|
||||||
|
Полностью отсутствуют в схеме (проверено по всему сайту): `OpenReason`,
|
||||||
|
`OpenReasonDetails`, `HireDate`.
|
||||||
|
|
||||||
|
Из-за всего этого отчёт по вакансиям в `tnsu-approval-route` остался на items REST по
|
||||||
|
партициям, а не на Search — подробный расчёт в doc-комментарии `VacancyReportService`.
|
||||||
|
|
||||||
|
**Права разорваны на КАЖДОМ элементе.** У заявок портала
|
||||||
|
`HasUniqueRoleAssignments = true` поштучно: доступ выдан только участникам заявки плюс
|
||||||
|
общему админу. Поэтому под своей учёткой в списке видно 2 элемента из 19 — и это не
|
||||||
|
баг и не кэш браузера, а модель доступа. App-only токен (сертификат) это не трогает и
|
||||||
|
читает всё. Если сравниваете «сколько элементов в списке», обязательно фиксируйте, под
|
||||||
|
кем смотрите: `ItemCount` в метаданных списка считает ВСЁ, а `/items` — только
|
||||||
|
доступное вам.
|
||||||
|
|||||||
Reference in New Issue
Block a user