Compare commits

...

8 Commits

Author SHA1 Message Date
Тимур Городецкий b18cf50efd docs: add responsive layout checks to the test plan
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:19:59 +05:00
Тимур Городецкий 76878c7617 docs: record report fixes and the import test blocker
Import tests wait for an estimate in the catalogue: the API returns
totalCount 0 for every project.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:57 +05:00
Тимур Городецкий f05ee3e854 fix: number a new report after the highest number in the project
Counting non-deleted reports reused numbers (and photo folders) after a
soft delete. Deleted reports are now included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:57 +05:00
Тимур Городецкий f6af34cb0a fix: send the back link to the reports list when there is no history
Reports open from the list in a new tab, where history.back() did nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:56 +05:00
Тимур Городецкий 40a39cf828 fix: show the approved badge right after approving a report
Previously the badge stayed "На утверждении" until a page reload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:56 +05:00
Тимур Городецкий ae383b5231 fix: show title, unit and plan for a work created from the picker
The new work was not yet in lookups of the current render, so the card
got the full path, an empty unit and plan 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:56 +05:00
Тимур Городецкий 33f31366cd fix: center the report picker on wide screens and keep the value field inside its card
The bottom sheet stays for phones only, as in the old picker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:56 +05:00
Тимур Городецкий f0272c2170 fix: check report access with the user id of the lists site
The user id differs per site, so the id from the page context showed
"Нет доступа" in reports when the page was not on /sites/stroy.
The id and the photo folder path now come from settings.siteUrl.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:17:56 +05:00
20 changed files with 209 additions and 54 deletions
+1 -1
View File
@@ -2,5 +2,5 @@
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json", "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/spfx-serve.schema.json",
"port": 4321, "port": 4321,
"https": true, "https": true,
"initialPage": "https://tnsukz.sharepoint.com/_layouts/workbench.aspx" "initialPage": "https://tnsukz.sharepoint.com/sites/stroy/_layouts/workbench.aspx"
} }
+12 -3
View File
@@ -14,6 +14,8 @@
## 0. Общее (оболочка) ## 0. Общее (оболочка)
> 🔧 **Исправлено при проверке T-RPL (2026-10-19):** «Отчёты по проекту» и форма отчёта брали id пользователя из контекста страницы (`legacyPageContext.userId`) и путь к DocLib — от сайта страницы. У каждого сайта свой id пользователя, поэтому в workbench корневого сайта «Отчёты» показывали «Нет доступа». Теперь всё — от сайта со списками (`settings.siteUrl`): id через `_api/web/currentuser`, путь папки фото — от `siteUrl`. Workbench по умолчанию открывается на `/sites/stroy`.
- [ ] Одна web part, разделы переключаются через `?page=…`, проект — через `?projectId=…` - [ ] Одна web part, разделы переключаются через `?page=…`, проект — через `?projectId=…`
- [ ] Кнопки «назад/вперёд» браузера переключают разделы - [ ] Кнопки «назад/вперёд» браузера переключают разделы
- [ ] Ссылку на любой раздел можно скопировать и открыть в новой вкладке - [ ] Ссылку на любой раздел можно скопировать и открыть в новой вкладке
@@ -185,18 +187,25 @@
### Сохранение ### Сохранение
- [ ] Валидация (все сообщения из исходника) - [ ] Валидация (все сообщения из исходника)
- [ ] Номер = количество неудалённых отчётов проекта + 1 - [ ] Номер = наибольший номер в проекте (включая удалённые) + 1 — в старом коде «количество неудалённых + 1», см. 🔧 ниже
- [ ] При правке — удалить все старые строки (сначала дочерние), создать заново - [ ] При правке — удалить все старые строки (сначала дочерние), создать заново
- [ ] Фото → `DocLib/{projectId}_{number}` - [ ] Фото → `DocLib/{projectId}_{number}`
- [ ] «Отчет успешно сохранен!», кнопка меняется на «← Вернуться назад» - [ ] «Отчет успешно сохранен!», кнопка меняется на «← Вернуться назад»
- [ ] Утверждение: подтверждение → `cree9_aprooved = true` → «Отчет утвержден!» - [ ] Утверждение: подтверждение → `cree9_aprooved = true` → «Отчет утвержден!»
- [ ] Права: «Доступы» `projectId` + `userId*` (несколько вариантов имени поля) - [ ] Права: «Доступы» `projectId` + `userId*` (несколько вариантов имени поля)
🐞 Create без проверки NoAccess. 🐞 Номер может совпасть после мягкого удаления. 🐞 Дата «сегодня» — UTC. 🐞 Create без проверки NoAccess. 🐞 Дата «сегодня» — UTC.
🔧 **Исправлено при проверке T-REP (2026-10-19):**
- Только что созданная из пикера работа попадала в карточку с полным путём вместо названия, без ед. изм. и с планом 0 (в старом коде так же).
- Окно выбора на экране шире 640px — по центру (было всегда прижато к низу), поле «Значение» не вылезает за карточку.
- После «Утвердить отчет» бейдж оставался «На утверждении» до F5 — теперь сразу «Утверждён».
- «← Вернуться назад» во вкладке, открытой из списка отчётов (истории нет), ничего не делал — теперь ведёт в «Отчёты по проекту».
- Номер отчёта повторялся после мягкого удаления (номер = количество неудалённых + 1), а вместе с ним и папка фото `{projectId}_{номер}`. Теперь номер = наибольший номер в проекте, включая удалённые, + 1. Как нумерует отчёты Telegram-бот — уточнить у команды API.
**Сознательные отличия от report:** **Сознательные отличия от report:**
- `confirm('Утвердить отчет? …')` → `confirmModal` из ui-kit, текст тот же. - `confirm('Утвердить отчет? …')` → `confirmModal` из ui-kit, текст тот же.
- Адрес сайта для списков и ссылки на папку фото — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`); путь папки при создании — по-прежнему от сайта, где стоит страница (`web.serverRelativeUrl`). По умолчанию это один и тот же сайт `/sites/stroy`. - Адрес сайта для списков и ссылки на папку фото — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`); путь папки при создании и id пользователя для проверки прав — тоже от этого сайта (раньше — от сайта, где стоит страница). По умолчанию это один и тот же сайт `/sites/stroy`.
- Если Dataverse ответит на создание записи `204` без тела, старый код брал id из заголовка `OData-EntityId`, а новый клиент заголовков не видит. С `Prefer: return=representation` Dataverse отвечает `201` с телом, поэтому на практике не встречается; при пустом id сохранение останавливается той же ошибкой `reportId is empty`. - Если Dataverse ответит на создание записи `204` без тела, старый код брал id из заголовка `OData-EntityId`, а новый клиент заголовков не видит. С `Prefer: return=representation` Dataverse отвечает `201` с телом, поэтому на практике не встречается; при пустом id сохранение останавливается той же ошибкой `reportId is empty`.
- Логика режимов, валидации, сборки строк и порядка записи / удаления сверена со старым кодом построчно и покрыта тестами (45 тестов). - Логика режимов, валидации, сборки строк и порядка записи / удаления сверена со старым кодом построчно и покрыта тестами (45 тестов).
+20
View File
@@ -120,6 +120,9 @@
- [ ] **T-WRK-09** Роль **Р** → нет кнопок добавления и меню ⋮. - [ ] **T-WRK-09** Роль **Р** → нет кнопок добавления и меню ⋮.
## 7. Импорт работ — `T-IMP` ## 7. Импорт работ — `T-IMP`
> ⛔ **T-IMP-02…07 заблокированы (2026-10-19):** сначала `GET https://api.tnsu.kz/Project/{oid}/works` отвечал 500 `42703: column l.name does not exist` — команда API исправила. Теперь он отвечает 200, но `totalCount: 0` у всех проектов: сметы в справочник ещё не загружены. Старый web part показывает то же «Работы не найдены». Повторить, когда появится хотя бы один проект со сметой (тестовый проект 51 привязать к нему; настоящие проекты не привязывать). T-IMP-01 ✅.
> Проверено по коду: API урезает `pageSize=200` до 100, но число страниц берётся из `totalPages` ответа — работы не теряются.
- [ ] **T-IMP-01** Проект без привязки к справочнику → «Проект не привязан к справочнику…». - [ ] **T-IMP-01** Проект без привязки к справочнику → «Проект не привязан к справочнику…».
- [ ] **T-IMP-02** Большой привязанный проект: «Загрузка работ… X из Y», затем число работ совпадает со старой страницей. - [ ] **T-IMP-02** Большой привязанный проект: «Загрузка работ… X из Y», затем число работ совпадает со старой страницей.
- [ ] **T-IMP-03** Поиск (код СНБ, объём, название), страницы по 100. - [ ] **T-IMP-03** Поиск (код СНБ, объём, название), страницы по 100.
@@ -158,10 +161,27 @@
- [ ] **T-REP-18** Роль **Р** → отчёт только для чтения, кнопок нет. - [ ] **T-REP-18** Роль **Р** → отчёт только для чтения, кнопок нет.
- [ ] **T-REP-19** Роль **Н** → «Нет доступа» при открытии существующего отчёта. - [ ] **T-REP-19** Роль **Н** → «Нет доступа» при открытии существующего отчёта.
- [ ] **T-REP-20** Большой проект: открыть 3 разных старых отчёта → всё совпадает со старой формой (план-факт, персонал, техника, фото-папка). - [ ] **T-REP-20** Большой проект: открыть 3 разных старых отчёта → всё совпадает со старой формой (план-факт, персонал, техника, фото-папка).
- Проверено на проекте 17: №2030 и ещё 2 отчёта совпали, режим правки тот же. Отчёт с персоналом/техникой **внутри работы** найти не удалось (прямого доступа к Dataverse нет) — вложенные строки проверены только на тестовом проекте (T-REP-10/11). Дополнительно: встретится такой отчёт — сверить со старой формой.
- [ ] **T-REP-21** «← Вернуться назад» → вернулись туда, откуда пришли. - [ ] **T-REP-21** «← Вернуться назад» → вернулись туда, откуда пришли.
- [ ] **T-REP-22** Enter в любом поле не перезагружает страницу. - [ ] **T-REP-22** Enter в любом поле не перезагружает страницу.
- [ ] ✍️ **T-REP-23** Удалить тестовый отчёт из «Отчёты по проекту» → «Удалить отчёт?» → пропал из списка (мягкое удаление). - [ ] ✍️ **T-REP-23** Удалить тестовый отчёт из «Отчёты по проекту» → «Удалить отчёт?» → пропал из списка (мягкое удаление).
## 10. Адаптивность — `T-RSP`
> Как: F12 → кнопка «телефон/планшет» (Ctrl+Shift+M) → сверху выбрать ширину → F5. Только просмотр, ничего не сохранять.
> Ширины: **360** (обычный телефон), **768** (планшет), **1280** (ноутбук), плюс твой монитор как есть. На каждой ширине — признаки бага:
> горизонтальная прокрутка всей страницы, текст/кнопка вылезает за край, элементы налезают друг на друга, что-то нельзя нажать.
- [ ] **T-RSP-01** 360: меню скрыто, кнопка ☰ открывает его поверх, выбор раздела закрывает меню.
- [ ] **T-RSP-02** 360: главная — выбор проекта, даты в столбик, карточки в одну колонку.
- [ ] **T-RSP-03** 360: «Отчёты по проекту» — фильтр дат над поиском, карточки в одну колонку, кнопки копирования/удаления нажимаются.
- [ ] **T-RSP-04** 360: форма отчёта — поля во всю ширину, «Кол-во/Часы» друг под другом, пикер — шторка снизу, список прокручивается, «Сохранить» видна.
- [ ] **T-RSP-05** 360: «Объекты» и «Работы» — дерево не уезжает вправо, длинные названия переносятся/обрезаются, меню ⋮ не уходит за экран.
- [ ] **T-RSP-06** 360: «Ресурсы», «Привязка», «Импорт» — колонки складываются, модалки помещаются в экран.
- [ ] **T-RSP-07** 768: все разделы — то же беглым просмотром; пикер отчёта — окно по центру.
- [ ] **T-RSP-08** 1280 и свой монитор: меню слева, ничего не растянуто на всю ширину неудобно, форма отчёта — узкая колонка.
- [ ] **T-RSP-09** После деплоя: открыть страницу на **настоящем телефоне** (браузер и приложение SharePoint) — меню, форма отчёта, пикер (сохранять только на тестовом проекте 51).
--- ---
## Как сообщить о баге ## Как сообщить о баге
+18 -13
View File
@@ -1,18 +1,23 @@
import * as React from 'react'; import * as React from 'react';
import { useRouter } from '../../app/routing/RouterContext';
import { goBack } from './goBack'; import { goBack } from './goBack';
import { REPORT_TEXTS } from './texts'; import { REPORT_TEXTS } from './texts';
import styles from './ReportPage.module.scss'; import styles from './ReportPage.module.scss';
/** «← Вернуться назад» — назад по истории браузера, как в старой форме. */ /** «← Вернуться назад» — назад по истории браузера, как в старой форме; без истории — к списку отчётов проекта. */
export const BackLink: React.FC = () => ( export const BackLink: React.FC = () => {
<a const { route, navigate } = useRouter();
href="#"
className={styles.backLink} return (
onClick={(event) => { <a
event.preventDefault(); href="#"
goBack(); className={styles.backLink}
}} onClick={(event) => {
> event.preventDefault();
{REPORT_TEXTS.back} goBack(() => navigate({ page: 'reports', projectId: route.projectId }));
</a> }}
); >
{REPORT_TEXTS.back}
</a>
);
};
@@ -138,6 +138,7 @@ $photo-thumb-min: 80px;
} }
.valueInput { .valueInput {
box-sizing: border-box; // иначе ширина 100% + отступы вылезают за край карточки работы
width: 100%; width: 100%;
padding: $sp-2 $sp-3; padding: $sp-2 $sp-3;
border: 1px solid $gray-300; border: 1px solid $gray-300;
@@ -26,8 +26,8 @@ const bodyOf = (body: string | ArrayBuffer | undefined): unknown => JSON.parse(S
describe('адреса Dataverse (1:1 со stroy-bot-report)', () => { describe('адреса Dataverse (1:1 со stroy-bot-report)', () => {
it('отчёт, счётчик, строки, факт', () => { it('отчёт, счётчик, строки, факт', () => {
expect(DV_PATHS.report('r1')).toEqual('cree9_reports(r1)'); expect(DV_PATHS.report('r1')).toEqual('cree9_reports(r1)');
expect(DV_PATHS.reportCount(5)).toEqual( expect(DV_PATHS.lastReportNumber(5)).toEqual(
'cree9_reports?$filter=cree9_projectid eq 5 and cree9_deleted ne true&$count=true&$top=1&$select=cree9_number' 'cree9_reports?$filter=cree9_projectid eq 5&$orderby=cree9_number desc&$top=1&$select=cree9_number'
); );
expect(DV_PATHS.reportData('r1')).toEqual('cree9_report_datas?$filter=_cree9_column1_value eq r1'); expect(DV_PATHS.reportData('r1')).toEqual('cree9_report_datas?$filter=_cree9_column1_value eq r1');
expect(DV_PATHS.jobTotalFact('j1')).toEqual('cree9_report_datas?$filter=_cree9_jobid_value eq j1&$select=cree9_value'); expect(DV_PATHS.jobTotalFact('j1')).toEqual('cree9_report_datas?$filter=_cree9_jobid_value eq j1&$select=cree9_value');
@@ -165,11 +165,18 @@ describe('saveReportWithData', () => {
}); });
describe('прочие операции', () => { describe('прочие операции', () => {
it('номер нового отчёта = количество неудалённых + 1', async () => { it('номер нового отчёта = наибольший номер в проекте (включая удалённые) + 1', async () => {
const client = createFakeHttpClient([{ match: '$count=true', response: { body: { '@odata.count': 7, value: [] } } }]); const client = createFakeHttpClient([
{ match: 'cree9_number desc', response: { body: { value: [{ cree9_number: 7 }] } } }
]);
expect(await createDataverseReportRepository(client, DV).getNextReportNumber(5)).toEqual(8); expect(await createDataverseReportRepository(client, DV).getNextReportNumber(5)).toEqual(8);
}); });
it('первый отчёт проекта получает номер 1', async () => {
const client = createFakeHttpClient([{ match: 'cree9_number desc', response: { body: { value: [] } } }]);
expect(await createDataverseReportRepository(client, DV).getNextReportNumber(5)).toEqual(1);
});
it('утверждение — PATCH cree9_aprooved: true', async () => { it('утверждение — PATCH cree9_aprooved: true', async () => {
const client = createFakeHttpClient([{ match: 'cree9_reports(r1)', response: { status: 204 } }]); const client = createFakeHttpClient([{ match: 'cree9_reports(r1)', response: { status: 204 } }]);
await createDataverseReportRepository(client, DV).approveReport('r1'); await createDataverseReportRepository(client, DV).approveReport('r1');
@@ -50,9 +50,10 @@ export const DV_PATHS = {
`?$select=ReferencingAttribute,ReferencingEntityNavigationPropertyName` + `?$select=ReferencingAttribute,ReferencingEntityNavigationPropertyName` +
`&$filter=ReferencingAttribute eq 'cree9_parentid'`, `&$filter=ReferencingAttribute eq 'cree9_parentid'`,
report: (reportId: string): string => `${DV_TABLES.reports}(${reportId})`, report: (reportId: string): string => `${DV_TABLES.reports}(${reportId})`,
reportCount: (projectId: number): string => // Удалённые отчёты не исключаются: их номера (и папки фото {projectId}_{номер}) повторно не выдаются.
`${DV_TABLES.reports}?$filter=cree9_projectid eq ${projectId} and cree9_deleted ne true` + lastReportNumber: (projectId: number): string =>
`&$count=true&$top=1&$select=cree9_number`, `${DV_TABLES.reports}?$filter=cree9_projectid eq ${projectId}` +
`&$orderby=cree9_number desc&$top=1&$select=cree9_number`,
jobTotalFact: (jobId: string): string => jobTotalFact: (jobId: string): string =>
`${DV_TABLES.reportData}?$filter=_cree9_jobid_value eq ${jobId}&$select=cree9_value`, `${DV_TABLES.reportData}?$filter=_cree9_jobid_value eq ${jobId}&$select=cree9_value`,
reportData: (reportId: string): string => `${DV_TABLES.reportData}?$filter=_cree9_column1_value eq ${reportId}`, reportData: (reportId: string): string => `${DV_TABLES.reportData}?$filter=_cree9_column1_value eq ${reportId}`,
@@ -252,9 +253,11 @@ export function createDataverseReportRepository(client: IHttpClient, baseUrl: st
getReportById: (reportId) => get<IReportRow>(DV_PATHS.report(reportId)), getReportById: (reportId) => get<IReportRow>(DV_PATHS.report(reportId)),
getNextReportNumber: async (projectId) => { getNextReportNumber: async (projectId) => {
// 🐞 Номер = число неудалённых отчётов + 1: после мягкого удаления номер может повториться. // В старом коде номер = число неудалённых отчётов + 1, и после мягкого удаления номера повторялись —
const result = await get<{ '@odata.count'?: number }>(DV_PATHS.reportCount(projectId)); // исправлено при проверке T-REP-23: номер = наибольший номер в проекте + 1.
return (result['@odata.count'] || 0) + 1; const result = await get<{ value?: Array<{ cree9_number?: number }> }>(DV_PATHS.lastReportNumber(projectId));
const lastNumber = result.value?.[0]?.cree9_number || 0;
return lastNumber + 1;
}, },
approveReport: (reportId) => patch(DV_PATHS.report(reportId), { cree9_aprooved: true }), approveReport: (reportId) => patch(DV_PATHS.report(reportId), { cree9_aprooved: true }),
+10 -1
View File
@@ -1,8 +1,17 @@
/** В новой вкладке (так отчёты открываются из списка) история состоит из одной текущей страницы. */
const NO_HISTORY_LENGTH = 1;
/** /**
* «← Вернуться назад» — как в старом коде: если страница открыта во фрейме, назад идёт верхнее окно, * «← Вернуться назад» — как в старом коде: если страница открыта во фрейме, назад идёт верхнее окно,
* при ошибке доступа к нему (другой домен) — текущее. * при ошибке доступа к нему (другой домен) — текущее.
* Если возвращаться некуда (вкладка открыта из списка отчётов), вызывается onNoHistory: в старом коде
* кнопка в этом случае ничего не делала — исправлено при проверке T-REP-21.
*/ */
export function goBack(): void { export function goBack(onNoHistory: () => void): void {
if (window.history.length <= NO_HISTORY_LENGTH) {
onNoHistory();
return;
}
try { try {
if (window.top && window.top !== window) { if (window.top && window.top !== window) {
window.top.history.back(); window.top.history.back();
@@ -42,6 +42,18 @@ $slide-duration: 0.2s;
animation: slideUp $slide-duration ease-out; animation: slideUp $slide-duration ease-out;
} }
// Как в старом PickerModal: шторка снизу — только на телефоне, на экране шире 640px — окно по центру.
@include above($bp-sm) {
.overlay {
align-items: center;
}
.sheet {
border-radius: $r-lg;
animation: none;
}
}
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1,4 +1,5 @@
import { IAppServices } from '../../services/AppServices'; import { IAppServices } from '../../services/AppServices';
import { siteServerRelativeUrl } from '../../services/siteUser';
import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository'; import { createSharePointReportRepository, ISharePointReportRepository } from './sharePointReportRepository';
import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository'; import { createDataverseReportRepository, IDataverseReportRepository } from './dataverseReportRepository';
@@ -9,13 +10,15 @@ export interface IReportServices {
siteUrl: string; siteUrl: string;
} }
// Всё — от сайта со списками (settings.siteUrl), а не от сайта страницы: в workbench корневого
// сайта id пользователя и путь к библиотеке DocLib там другие.
export function createReportServices(services: IAppServices): IReportServices { export function createReportServices(services: IAppServices): IReportServices {
return { return {
sp: createSharePointReportRepository({ sp: createSharePointReportRepository({
client: services.sharePoint, client: services.sharePoint,
siteUrl: services.settings.siteUrl, siteUrl: services.settings.siteUrl,
userId: services.page.userId, getCurrentUserId: services.getCurrentUserId,
webServerRelativeUrl: services.page.webServerRelativeUrl webServerRelativeUrl: siteServerRelativeUrl(services.settings.siteUrl)
}), }),
dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl), dv: createDataverseReportRepository(services.dataverse, services.settings.dataverseUrl),
siteUrl: services.settings.siteUrl siteUrl: services.settings.siteUrl
@@ -17,7 +17,7 @@ const repositoryWith = (routes: IFakeRoute[]): {
const repository = createSharePointReportRepository({ const repository = createSharePointReportRepository({
client, client,
siteUrl: SITE, siteUrl: SITE,
userId: 42, getCurrentUserId: async () => 42,
webServerRelativeUrl: '/sites/stroy/' webServerRelativeUrl: '/sites/stroy/'
}); });
return { client, repository }; return { client, repository };
@@ -111,9 +111,13 @@ export function fileAddUrl(siteUrl: string, serverRelativeFolderUrl: string, fil
export interface ISharePointReportDeps { export interface ISharePointReportDeps {
client: IHttpClient; client: IHttpClient;
siteUrl: string; siteUrl: string;
/** Id текущего пользователя (legacyPageContext.userId) — по нему ищется строка «Доступы». */ /**
userId: number | undefined; * Id текущего пользователя на сайте со списками — по нему ищется строка «Доступы».
/** Серверный относительный URL сайта — для пути к папке фото. */ * Старый код брал legacyPageContext.userId (id на сайте страницы); в workbench корневого сайта
* это другой id, и доступ не находился. Теперь — `_api/web/currentuser` сайта со списками.
*/
getCurrentUserId: () => Promise<number>;
/** Серверный относительный URL сайта со списками (`/sites/stroy`) — для пути к папке фото. */
webServerRelativeUrl: string; webServerRelativeUrl: string;
} }
@@ -224,8 +228,11 @@ export function createSharePointReportRepository(deps: ISharePointReportDeps): I
createListItem, createListItem,
checkAccess: async (projectId) => { checkAccess: async (projectId) => {
const rows = await getItems<IAccessRaw>(LISTS.access, { filter: `projectId eq ${projectId}` }); const [rows, userId] = await Promise.all([
return pickAccess(rows, deps.userId); getItems<IAccessRaw>(LISTS.access, { filter: `projectId eq ${projectId}` }),
deps.getCurrentUserId()
]);
return pickAccess(rows, userId);
}, },
uploadPhotos: async (projectId, reportNumber, photos) => { uploadPhotos: async (projectId, reportNumber, photos) => {
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { LISTS } from '../../config/lists'; import { LISTS } from '../../config/lists';
import { IReportLookups, IWorkItem } from './reportTypes'; import { IJobWithPath, IReportLookups, IWorkItem } from './reportTypes';
import { IPickerCreateData, IPickerItem, IPickerOptions } from './picker/pickerTypes'; import { IPickerCreateData, IPickerItem, IPickerOptions } from './picker/pickerTypes';
import { PickerKind, PICKER_OPTIONS, toPickerItems } from './reportPicker'; import { PickerKind, PICKER_OPTIONS, toPickerItems } from './reportPicker';
import { addResource, addWork, createLocalId, createResourceItem } from './reportFormUpdates'; import { addResource, addWork, createLocalId, createResourceItem } from './reportFormUpdates';
@@ -32,8 +32,14 @@ interface IPickerDeps {
projectId: number; projectId: number;
} }
function newWorkItem(item: IPickerItem, lookups: IReportLookups, totalFact: number): IWorkItem { function newWorkItem(
const job = lookups.jobs.filter((candidate) => candidate.cree9_tableid === item.id)[0]; item: IPickerItem,
lookups: IReportLookups,
createdJobs: Map<string, IJobWithPath>,
totalFact: number
): IWorkItem {
const job =
lookups.jobs.filter((candidate) => candidate.cree9_tableid === item.id)[0] ?? createdJobs.get(String(item.id));
return { return {
id: createLocalId(), id: createLocalId(),
jobId: String(item.id), jobId: String(item.id),
@@ -75,6 +81,10 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
const { services, report } = deps; const { services, report } = deps;
const [active, setActive] = React.useState<IActivePicker | undefined>(undefined); const [active, setActive] = React.useState<IActivePicker | undefined>(undefined);
const [isLoading, setIsLoading] = React.useState<boolean>(false); const [isLoading, setIsLoading] = React.useState<boolean>(false);
// Работы, созданные из пикера в этой форме. Нужны сразу при выборе: в lookups.jobs текущего рендера
// их ещё нет (обновление state придёт позже), и без этого карточка получала путь вместо названия,
// пустую ед. изм. и план 0 — баг старого кода, исправлен при проверке T-REP-15b.
const createdJobsRef = React.useRef<Map<string, IJobWithPath>>(new Map());
const open = (kind: PickerKind, targetWorkId?: string): void => { const open = (kind: PickerKind, targetWorkId?: string): void => {
setActive({ kind, targetWorkId }); setActive({ kind, targetWorkId });
@@ -100,10 +110,8 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
} else if (kind === 'company') { } else if (kind === 'company') {
report.updateForm((prev) => ({ ...prev, companyId: Number(item.id), companyName: item.title })); report.updateForm((prev) => ({ ...prev, companyId: Number(item.id), companyName: item.title }));
} else if (kind === 'work') { } else if (kind === 'work') {
// 🐞 Как в старом коде: только что созданной работы ещё нет в lookups.jobs этого рендера,
// поэтому у неё название = полный путь, ед. изм. пустая и план 0.
const totalFact = await services.dv.getJobTotalFact(String(item.id)); const totalFact = await services.dv.getJobTotalFact(String(item.id));
const work = newWorkItem(item, report.lookups, totalFact); const work = newWorkItem(item, report.lookups, createdJobsRef.current, totalFact);
report.updateForm((prev) => addWork(prev, work)); report.updateForm((prev) => addWork(prev, work));
} else { } else {
const resource = createResourceItem(Number(item.id), item.title); const resource = createResourceItem(Number(item.id), item.title);
@@ -134,7 +142,9 @@ export function usePickerModal(deps: IPickerDeps): IPickerModalApi {
parentId: data.parentId parentId: data.parentId
}); });
const fullPath = newJobPath(data.title, data.parentId, report.lookups.jobs); const fullPath = newJobPath(data.title, data.parentId, report.lookups.jobs);
report.updateLookups((prev) => ({ jobs: [...prev.jobs, { ...created, fullPath }] })); const createdWithPath = { ...created, fullPath };
createdJobsRef.current.set(created.cree9_tableid, createdWithPath);
report.updateLookups((prev) => ({ jobs: [...prev.jobs, createdWithPath] }));
return { id: created.cree9_tableid, title: fullPath, subtitle: data.unit }; return { id: created.cree9_tableid, title: fullPath, subtitle: data.unit };
}; };
@@ -115,7 +115,8 @@ export function useReportForm(services: IReportServices, params: IReportRoutePar
try { try {
await services.dv.approveReport(existingReportId); await services.dv.approveReport(existingReportId);
setSuccessMessage(MESSAGES.approved); setSuccessMessage(MESSAGES.approved);
// 🐞 Как в старом коде: флаг «утверждён» не обновляется, бейдж остаётся «На утверждении». // В старом коде флаг не обновлялся и бейдж оставался «На утверждении» до F5 — исправлено при проверке T-REP-16.
setIsApproved(true);
setMode('readonly'); setMode('readonly');
} catch (error) { } catch (error) {
setErrorMessage(MESSAGES.approveError(messageOf(error))); setErrorMessage(MESSAGES.approveError(messageOf(error)));
@@ -11,7 +11,8 @@ export interface IReportListDeps {
dataverse: IHttpClient; dataverse: IHttpClient;
siteUrl: string; siteUrl: string;
dataverseUrl: string; dataverseUrl: string;
userId: number | undefined; /** Id пользователя на сайте со списками (не на сайте страницы) — см. IAppServices.getCurrentUserId. */
getCurrentUserId: () => Promise<number>;
} }
/** Справочники для подписей карточек: компания по Id, путь объекта. */ /** Справочники для подписей карточек: компания по Id, путь объекта. */
@@ -49,7 +50,8 @@ export async function loadReportList(
projectId: number, projectId: number,
range: IDateRange range: IDateRange
): Promise<ReportListInitResult> { ): Promise<ReportListInitResult> {
const accessResult = await fetchReportAccess(deps.sharePoint, deps.siteUrl, deps.userId, projectId); const userId = await deps.getCurrentUserId();
const accessResult = await fetchReportAccess(deps.sharePoint, deps.siteUrl, userId, projectId);
if (!accessResult) return { kind: 'denied' }; if (!accessResult) return { kind: 'denied' };
const base = { kind: 'ready' as const, projectName: accessResult.projectName, access: accessResult.access }; const base = { kind: 'ready' as const, projectName: accessResult.projectName, access: accessResult.access };
@@ -149,7 +149,7 @@ describe('loadReportList', () => {
} }
} }
]); ]);
const deps = { sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }; const deps = { sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 };
const result = await loadReportList(deps, 5, WEEK); const result = await loadReportList(deps, 5, WEEK);
@@ -174,7 +174,7 @@ describe('loadReportList', () => {
it('нет доступа — denied, справочники не грузятся', async () => { it('нет доступа — denied, справочники не грузятся', async () => {
const sharePoint = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]); const sharePoint = createFakeHttpClient([{ match: 'Доступы', response: { body: { value: [] } } }]);
const dataverse = createFakeHttpClient([]); const dataverse = createFakeHttpClient([]);
const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }, 5, WEEK); const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 }, 5, WEEK);
expect(result).toEqual({ kind: 'denied' }); expect(result).toEqual({ kind: 'denied' });
expect(sharePoint.requests).toHaveLength(1); expect(sharePoint.requests).toHaveLength(1);
}); });
@@ -182,7 +182,7 @@ describe('loadReportList', () => {
it('ошибка отчётов — экран с «Ошибка инициализации: …» и без карточек', async () => { it('ошибка отчётов — экран с «Ошибка инициализации: …» и без карточек', async () => {
const sharePoint = createFakeHttpClient([access, companies, objects]); const sharePoint = createFakeHttpClient([access, companies, objects]);
const dataverse = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 500, body: 'fail' } }]); const dataverse = createFakeHttpClient([{ match: 'cree9_reports', response: { status: 500, body: 'fail' } }]);
const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, userId: 42 }, 5, WEEK); const result = await loadReportList({ sharePoint, dataverse, siteUrl: SITE, dataverseUrl: DV, getCurrentUserId: async () => 42 }, 5, WEEK);
expect(result).toMatchObject({ expect(result).toMatchObject({
kind: 'ready', kind: 'ready',
reports: undefined, reports: undefined,
@@ -26,7 +26,7 @@ export interface IReportListState {
/** Данные страницы отчётов: первая загрузка, «Применить» и удаление. */ /** Данные страницы отчётов: первая загрузка, «Применить» и удаление. */
export function useReportList(projectId: number, initialRange: IDateRange): IReportListState { export function useReportList(projectId: number, initialRange: IDateRange): IReportListState {
const { sharePoint, dataverse, settings, page } = useAppServices(); const { sharePoint, dataverse, settings, getCurrentUserId } = useAppServices();
const [phase, setPhase] = React.useState<ReportListPhase>('loading'); const [phase, setPhase] = React.useState<ReportListPhase>('loading');
const [projectName, setProjectName] = React.useState<string>(''); const [projectName, setProjectName] = React.useState<string>('');
const [access, setAccess] = React.useState<IReportAccess>(NO_ACCESS); const [access, setAccess] = React.useState<IReportAccess>(NO_ACCESS);
@@ -44,9 +44,9 @@ export function useReportList(projectId: number, initialRange: IDateRange): IRep
dataverse, dataverse,
siteUrl: settings.siteUrl, siteUrl: settings.siteUrl,
dataverseUrl: settings.dataverseUrl, dataverseUrl: settings.dataverseUrl,
userId: page.userId getCurrentUserId
}), }),
[sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, page.userId] [sharePoint, dataverse, settings.siteUrl, settings.dataverseUrl, getCurrentUserId]
); );
React.useEffect(() => { React.useEffect(() => {
+11 -2
View File
@@ -2,6 +2,7 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
import { IHttpClient } from './http/IHttpClient'; import { IHttpClient } from './http/IHttpClient';
import { createAadClient, createSharePointClient } from './http/spfxClients'; import { createAadClient, createSharePointClient } from './http/spfxClients';
import { DATAVERSE_URL, SITE_URL, TNSU_API_BASE_URL, TNSU_API_RESOURCE_ID } from '../config/constants'; import { DATAVERSE_URL, SITE_URL, TNSU_API_BASE_URL, TNSU_API_RESOURCE_ID } from '../config/constants';
import { createCurrentUserIdProvider } from './siteUser';
/** Настройки подключения из property pane (пустое значение — берётся значение по умолчанию). */ /** Настройки подключения из property pane (пустое значение — берётся значение по умолчанию). */
export interface IConnectionSettings { export interface IConnectionSettings {
@@ -34,6 +35,12 @@ export interface IAppServices {
tnsuApi: IHttpClient; tnsuApi: IHttpClient;
settings: IResolvedSettings; settings: IResolvedSettings;
page: IPageInfo; page: IPageInfo;
/**
* Id текущего пользователя на сайте со списками (settings.siteUrl). Id пользователя у каждого сайта
* свой: legacyPageContext.userId — это id на сайте страницы, и в workbench корневого сайта он не
* совпадает с id в «Доступах» сайта stroy. Запрашивается один раз.
*/
getCurrentUserId: () => Promise<number>;
} }
function withoutTrailingSlash(url: string): string { function withoutTrailingSlash(url: string): string {
@@ -60,11 +67,13 @@ function readPageInfo(context: WebPartContext): IPageInfo {
export function createAppServices(context: WebPartContext, settings: IConnectionSettings): IAppServices { export function createAppServices(context: WebPartContext, settings: IConnectionSettings): IAppServices {
const resolved = resolveSettings(settings); const resolved = resolveSettings(settings);
const sharePoint = createSharePointClient(context);
return { return {
sharePoint: createSharePointClient(context), sharePoint,
dataverse: createAadClient(context, resolved.dataverseUrl), dataverse: createAadClient(context, resolved.dataverseUrl),
tnsuApi: createAadClient(context, TNSU_API_RESOURCE_ID), tnsuApi: createAadClient(context, TNSU_API_RESOURCE_ID),
settings: resolved, settings: resolved,
page: readPageInfo(context) page: readPageInfo(context),
getCurrentUserId: createCurrentUserIdProvider(sharePoint, resolved.siteUrl)
}; };
} }
@@ -0,0 +1,33 @@
import { createCurrentUserIdProvider, siteServerRelativeUrl } from './siteUser';
import { createFakeHttpClient } from '../shared/testing/fakeHttpClient';
const SITE = 'https://tnsukz.sharepoint.com/sites/stroy';
describe('siteServerRelativeUrl', () => {
it('путь сайта без домена', () => {
expect(siteServerRelativeUrl(SITE)).toEqual('/sites/stroy');
expect(siteServerRelativeUrl('https://tnsukz.sharepoint.com')).toEqual('/');
});
});
describe('createCurrentUserIdProvider', () => {
it('спрашивает currentuser у сайта со списками (не у сайта страницы) и запоминает', async () => {
const sharePoint = createFakeHttpClient([{ match: 'currentuser', response: { body: { Id: 19 } } }]);
const getCurrentUserId = createCurrentUserIdProvider(sharePoint, SITE);
expect(await getCurrentUserId()).toEqual(19);
expect(await getCurrentUserId()).toEqual(19);
expect(sharePoint.requests).toHaveLength(1);
expect(sharePoint.requests[0].url).toEqual(`${SITE}/_api/web/currentuser?$select=Id`);
});
it('неудачный запрос не запоминается — следующий вызов спрашивает снова', async () => {
const sharePoint = createFakeHttpClient([
{ match: 'currentuser', respond: (call) => (call === 1 ? { status: 503 } : { body: { Id: 19 } }) }
]);
const getCurrentUserId = createCurrentUserIdProvider(sharePoint, SITE);
await expect(getCurrentUserId()).rejects.toThrow('503');
expect(await getCurrentUserId()).toEqual(19);
});
});
@@ -0,0 +1,24 @@
import { IHttpClient } from './http/IHttpClient';
import { fetchCurrentUserId } from '../shared/sharepoint/spRest';
// Пользователь и пути — всегда относительно сайта со списками (settings.siteUrl), а не сайта страницы:
// id пользователя у каждого сайта свой, и в workbench корневого сайта он не совпадает с id в «Доступах».
/** Серверный относительный путь сайта (`/sites/stroy`) — для путей к папкам библиотек. */
export function siteServerRelativeUrl(siteUrl: string): string {
return siteUrl.replace(/^https?:\/\/[^/]+/, '') || '/';
}
/** Запоминает id пользователя; неудачный запрос не запоминается — следующий вызов спросит снова. */
export function createCurrentUserIdProvider(sharePoint: IHttpClient, siteUrl: string): () => Promise<number> {
let userIdPromise: Promise<number> | undefined;
return () => {
if (userIdPromise) return userIdPromise;
const request = fetchCurrentUserId(sharePoint, siteUrl).catch((error: unknown) => {
userIdPromise = undefined;
throw error;
});
userIdPromise = request;
return request;
};
}