Compare commits
8 Commits
cb810972c4
...
b18cf50efd
| Author | SHA1 | Date | |
|---|---|---|---|
| b18cf50efd | |||
| 76878c7617 | |||
| f05ee3e854 | |||
| f6af34cb0a | |||
| 40a39cf828 | |||
| ae383b5231 | |||
| 33f31366cd | |||
| f0272c2170 |
+1
-1
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 тестов).
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Как сообщить о баге
|
## Как сообщить о баге
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user