feat: add StroyBot app shell with URL routing
One web part replaces the separate stroy-bot-* web parts. The section and its parameters live in the page query string (?page=objects&projectId=5), so links can be copied, opened in a new tab and the browser Back button works. - shared config: site/Dataverse/API addresses, list and table names - HTTP layer behind IHttpClient so repositories can be unit-tested - ui-kit AppLayout + Sidebar, placeholder for sections not ported yet - migration checklist with every feature of the old web parts - design tokens mirrored from ui-kit instead of 8 copies Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+4
-6
@@ -2,17 +2,15 @@
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
|
||||
"version": "2.0",
|
||||
"bundles": {
|
||||
"stroy-bot-monorepo-web-part": {
|
||||
"stroy-bot-web-part": {
|
||||
"components": [
|
||||
{
|
||||
"entrypoint": "./lib/webparts/stroyBotMonorepo/StroyBotMonorepoWebPart.js",
|
||||
"manifest": "./src/webparts/stroyBotMonorepo/StroyBotMonorepoWebPart.manifest.json"
|
||||
"entrypoint": "./lib/webparts/stroyBot/StroyBotWebPart.js",
|
||||
"manifest": "./src/webparts/stroyBot/StroyBotWebPart.manifest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"externals": {},
|
||||
"localizedResources": {
|
||||
"StroyBotMonorepoWebPartStrings": "lib/webparts/stroyBotMonorepo/loc/{locale}.js"
|
||||
}
|
||||
"localizedResources": {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# Чек-лист миграции StroyBot → монорепо
|
||||
|
||||
Каждый раздел считается перенесённым только когда все пункты проверены в workbench.
|
||||
Пишущие операции (создание / правка / удаление / импорт) проверяем **только на тестовом проекте «ТЕСТ StroyBot»**.
|
||||
|
||||
Правила переноса:
|
||||
- Запросы, поля, фильтры, права и тексты переносятся **1:1** из исходного репозитория.
|
||||
- Известные баги переносятся как есть (помечены 🐞) — исправляем позже отдельными коммитами.
|
||||
- Старые страницы и web parts не трогаем, поддержка старых URL не нужна.
|
||||
|
||||
Легенда: `[ ]` — не проверено, `[x]` — проверено в workbench.
|
||||
|
||||
---
|
||||
|
||||
## 0. Общее (оболочка)
|
||||
|
||||
- [ ] Одна web part, разделы переключаются через `?page=…`, проект — через `?projectId=…`
|
||||
- [ ] Кнопки «назад/вперёд» браузера переключают разделы
|
||||
- [ ] Ссылку на любой раздел можно скопировать и открыть в новой вкладке
|
||||
- [ ] Выбранный проект сохраняется между перезагрузками (`sessionStorage`, ключ `stroybot_selected_project`)
|
||||
- [ ] Шрифты Inter и Lora подгружаются (раньше грузили report-list и assigner)
|
||||
- [ ] `webApiPermissionRequests` в одном пакете: `api.tnsu.kz` (`9232d9e2-…`, `Reports.Read` и `user_impersonation`) + Dataverse `https://tansu.crm5.dynamics.com` (`user_impersonation`)
|
||||
|
||||
**Сознательные изменения (не функционал, а настройка):**
|
||||
- Настройки property pane из 8 web parts сведены в одну группу «Подключение». Имена списков больше не настраиваются в property pane — они в константах.
|
||||
- `worksPageUrl` (objects → works) больше не нужен: переход внутри приложения.
|
||||
|
||||
---
|
||||
|
||||
## 1. Главная (stroy-bot-main)
|
||||
|
||||
### Выбор проекта
|
||||
- [ ] Поле «Проект:», плейсхолдер «Выберите проект», иконка поиска
|
||||
- [ ] Список грузится при первом фокусе, «Загрузка проектов…»
|
||||
- [ ] При фокусе текст выделяется
|
||||
- [ ] Фильтр по Title (без учёта регистра, подстрока), максимум **7** результатов
|
||||
- [ ] У каждого — бейдж статуса, «Без статуса» если пусто
|
||||
- [ ] Сообщения: «Нет доступных проектов», «Проекты не найдены», «Ошибка загрузки. Попробуйте ещё раз.»
|
||||
- [ ] Клик вне / Esc — закрыть и вернуть выбранное название (или очистить)
|
||||
- [ ] Бейдж статуса рядом с полем: текущий — зелёный, завершен/завершён — серый, архивный — золотой, иначе — нейтральный
|
||||
- [ ] Сортировка по алфавиту, затем до 7 недавно активных (из Dataverse `cree9_reports`) — наверх
|
||||
- [ ] Права из «Доступы»: `read | edit0 | aproove`, `canEdit` = OR по `edit0`
|
||||
- [ ] Подсказка «Выберите проект, чтобы активировать ссылки ниже»
|
||||
|
||||
### Разделы проекта
|
||||
- [ ] Объекты / «Объекты по проекту»
|
||||
- [ ] Отчёты по проекту / «Просмотр отчётности»
|
||||
- [ ] Отправить отчёт / «Отправка отчёта по проекту»
|
||||
- [ ] Загрузить работы из сметы / «Импорт работ в систему»
|
||||
- [ ] Архив проекта / «Библиотека документов» → `{SITE}/Shared Documents/Архив/{Title}` (внешняя ссылка, новая вкладка)
|
||||
- [ ] Ресурсы проекта / «Профессии и компании»
|
||||
- [ ] Без выбранного проекта карточки неактивны
|
||||
- [ ] Статус «архивный» — неактивно всё, кроме Архива; экспорт тоже неактивен
|
||||
- [ ] Статус «завершен/завершён» — неактивно «Отправить отчёт»
|
||||
- [ ] `canEdit = false` — неактивно «Отправить отчёт»
|
||||
|
||||
### Экспорт данных «Выгрузить за период»
|
||||
- [ ] Кнопка «?» — поповер с 3 строками (день / Пн–Вс / произвольный), закрытие по клику мимо
|
||||
- [ ] Поля «Начало» и «Конец» видны только при выбранном проекте
|
||||
- [ ] «Укажите период для активации ссылки», пока даты не заданы
|
||||
- [ ] Ошибка «Дата начала не может быть позже даты конца», кнопка неактивна
|
||||
- [ ] Бейдж: «Ежедневный отчёт» (`daily`) / «Еженедельный отчёт» (Пн–Вс, `weekly`) / «Отчёт за период» (`period`)
|
||||
- [ ] «Выгрузить» → спиннер «Формируется…» → файл `{prefix}_{from}_{to}.xlsx`
|
||||
- [ ] Запрос `GET https://api.tnsu.kz/Report/StroyBot?start=…&end=…&projectId=…` (unix UTC 00:00:00 / 23:59:59)
|
||||
- [ ] Ошибки: «Ошибка API: {status} {statusText}» / «Ошибка при загрузке отчёта»
|
||||
|
||||
🐞 Лишний запрос к «Доступы» без фильтра по пользователю.
|
||||
|
||||
---
|
||||
|
||||
## 2. Объекты (stroy-bot-objects) — `?page=objects&projectId=`
|
||||
|
||||
- [ ] Шапка «Объекты проекта» + название проекта («Загрузка...» / «Ошибка»)
|
||||
- [ ] «Добавить объект» (только `canEdit`), «Экспорт» → «Экспортируется...» → `objects-{projectId}.xlsx` (`GET https://api.tnsu.kz/Report/ProjectObjects?projectId=`)
|
||||
- [ ] Без projectId — «Вы не выбрали проект»; «Проект с ID={id} не найден»; «Ошибка: …»
|
||||
- [ ] Пусто — «Объекты не найдены» + «+ Добавить объект» (если `canEdit`)
|
||||
- [ ] Тулбар «{N} объектов» (рекурсивно), «Развернуть», «Свернуть»
|
||||
- [ ] Дерево: отступ, иконка папки/файла, счётчик детей, шеврон, клик по строке — выделить + раскрыть
|
||||
- [ ] Раскрытые узлы сохраняются между перезагрузками в рамках сессии
|
||||
- [ ] Уровни: 0 — Объект-пятно, 1 — Раздел, 2 — Вид работ (не глубже 3 уровней)
|
||||
- [ ] Контекстное меню (⋮ и правый клик, только `canEdit`): Добавить объект (не на уровне 2), Редактировать, Работы, Удалить
|
||||
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
|
||||
- [ ] Заголовки модалки: «Добавить Объект-пятно / Раздел / Вид работ», «Редактировать …», «— (корневой)»
|
||||
- [ ] Удаление: подтверждение с числом дочерних, «Это действие нельзя отменить.»
|
||||
- [ ] Drag & drop (только `canEdit`): до / внутрь / после, запрет на себя и потомков, ошибка «Не удалось переместить: …»
|
||||
- [ ] «Работы» → раздел Работы с `objectId`
|
||||
- [ ] После любой операции дерево перезагружается
|
||||
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
|
||||
- [ ] Права: «Доступы» `projectId`/`userId` → `edit0`
|
||||
|
||||
🐞 Перетаскивание меняет только родителя, порядок не сохраняется. 🐞 Удаление узла не удаляет детей.
|
||||
|
||||
---
|
||||
|
||||
## 3. Работы (stroy-bot-works) — `?page=works&projectId=&objectId=`
|
||||
|
||||
- [ ] Шапка с названием проекта
|
||||
- [ ] «Добавить работу» (только `canEdit`)
|
||||
- [ ] Таблица-дерево: #, Наименование, Ед. изм., Проектный объём (ru-RU, 3 знака), Дата (`dd.mm.yy`, «start – finish» / одна / «—»)
|
||||
- [ ] Родители свёрнуты по умолчанию, шеврон «Свернуть/Развернуть», нумерация по видимым строкам
|
||||
- [ ] «Загрузка данных...», «Работы не найдены» (+ кнопка), ошибки Dataverse
|
||||
- [ ] Меню строки ⋮ (`canEdit`): Добавить дочернюю, Редактировать, Удалить
|
||||
- [ ] Модалка: НАИМЕНОВАНИЕ *, ЕД. ИЗМ. (список «Единицы измерения», с пагинацией; текущее значение не из списка — добавить наверх), ПРОЕКТНЫЙ ОБЪЁМ (шаг 0.001), РОДИТЕЛЬСКАЯ РАБОТА (без себя и потомков), ОБЪЕКТ (read-only), ДАТА НАЧАЛА / ОКОНЧАНИЯ
|
||||
- [ ] «Сохранить и добавить еще» (в режиме добавления: модалка остаётся, родитель сохраняется)
|
||||
- [ ] Подтверждение удаления «Подтвердите удаление»
|
||||
- [ ] Dataverse `cree9_tables`, фильтр `cree9_objectid eq '{objectId}'`; без objectId — все работы
|
||||
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
|
||||
- [ ] Проект находится подъёмом по родителям объекта до непустого `project`
|
||||
|
||||
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Нет проверки «начало ≤ окончание».
|
||||
|
||||
---
|
||||
|
||||
## 4. Отчёты по проекту (stroy-bot-report-list) — `?page=reports&projectId=`
|
||||
|
||||
- [ ] Заголовок — название проекта
|
||||
- [ ] Нет доступа — «Нет доступа» / «У вас нет прав на просмотр отчётов данного проекта.»
|
||||
- [ ] Период по умолчанию — текущая неделя Пн–Вс; «С:», «По:», «Применить»
|
||||
- [ ] Поиск «Поиск по любому полю...» (дата, номер, автор, компания, путь объекта, комментарий)
|
||||
- [ ] Карточка «Отправить отчёт» (если `edit0 || aproove`)
|
||||
- [ ] Карточки: `#номер`, дата, «✓ Утверждено», Пользователь, Компания (или «ID: n»), Объект (путь), Комментарий
|
||||
- [ ] Копировать (`edit0 || aproove`) → форма отчёта с `copyFromId`
|
||||
- [ ] Удалить (`aproove`, или `edit0` и не утверждён) → «Удалить отчёт?» → мягкое удаление `cree9_deleted = true`
|
||||
- [ ] Клик / Enter / Space по карточке → форма отчёта с `reportId`
|
||||
- [ ] Сортировка: дата desc, номер desc
|
||||
- [ ] Пусто — «Отчёты не найдены за выбранный период.»
|
||||
- [ ] Права: «Доступы» `user/Id` + `project/Id` (lookup)
|
||||
|
||||
🐞 Нет пагинации Dataverse (`@odata.nextLink` не читается). 🐞 Неделя по умолчанию считается в UTC.
|
||||
|
||||
---
|
||||
|
||||
## 5. Форма отчёта (stroy-bot-report) — `?page=report&projectId=[&reportId=|©FromId=]`
|
||||
|
||||
### Режимы
|
||||
- [ ] Нет projectId — «Не указан projectId в URL»
|
||||
- [ ] `reportId`: NoAccess / Approve (`aproove && !approved`) / Edit (`edit0 && !approved && дней < editDays`, по умолчанию 5) / ReadOnly
|
||||
- [ ] `copyFromId`: Create, предзаполнены объект, компания, смена, комментарий, работы (с персоналом/техникой), персонал, техника; дата — сегодня, номер — 0, фото — пусто
|
||||
- [ ] Без параметров — Create, смена — первая из «Смены», дата — сегодня
|
||||
|
||||
### Поля
|
||||
- [ ] «← Вернуться назад»
|
||||
- [ ] Заголовок «Отчет №N» / «Новый отчет», «Проект: …», бейдж «Утверждён» / «На утверждении»
|
||||
- [ ] Дата отчета*, Смена*, Объект* (пикер с путями; смена объекта очищает работы), Компания* (с фильтром по «Компании по проекту», fallback — все)
|
||||
- [ ] Работы: «+ Выбрать работу» («Сначала выберите объект»), Значение, План-факт (П / Ф / +/-, цвет), Комментарий
|
||||
- [ ] Вложенные «Персонал на работе» / «Техника на работе»: Кол-во, Часы, «+ Добавить профессию / технику»
|
||||
- [ ] Персонал: Кол-во, Часы, Тип часов (работы / простоя), Комментарий (обязателен для простоя)
|
||||
- [ ] Техника: то же + «Часы ремонта» (комментарий обязателен для простоя и ремонта)
|
||||
- [ ] Общий комментарий (contentEditable), Enter не отправляет форму SharePoint
|
||||
- [ ] Фото: «Открыть папку с фото», «Добавить фото» (multiple), превью с удалением
|
||||
|
||||
### Пикер
|
||||
- [ ] Поиск, группы для техники («Фильтры», чипы, «Все»), drill-down для работ (крошки «Все работы › …», «Выбрать «X»», «Нет работ на этом уровне»)
|
||||
- [ ] Создание новой техники и новой работы (Ед. изм., План, Родительская работа)
|
||||
|
||||
### Сохранение
|
||||
- [ ] Валидация (все сообщения из исходника)
|
||||
- [ ] Номер = количество неудалённых отчётов проекта + 1
|
||||
- [ ] При правке — удалить все старые строки (сначала дочерние), создать заново
|
||||
- [ ] Фото → `DocLib/{projectId}_{number}`
|
||||
- [ ] «Отчет успешно сохранен!», кнопка меняется на «← Вернуться назад»
|
||||
- [ ] Утверждение: подтверждение → `cree9_aprooved = true` → «Отчет утвержден!»
|
||||
- [ ] Права: «Доступы» `projectId` + `userId*` (несколько вариантов имени поля)
|
||||
|
||||
🐞 Create без проверки NoAccess. 🐞 Номер может совпасть после мягкого удаления. 🐞 Дата «сегодня» — UTC.
|
||||
|
||||
---
|
||||
|
||||
## 6. Импорт работ (stroy-bot-import-works) — `?page=import&projectId=`
|
||||
|
||||
- [ ] «ИМПОРТ РАБОТ» + название проекта
|
||||
- [ ] Нет projectId — «Не указан projectId в URL»; нет `UniqueId0` — «Проект не привязан к справочнику проектов. Обратитесь к администратору.»
|
||||
- [ ] Загрузка работ из API постранично по 200 с прогрессом «Загрузка работ... X из Y»
|
||||
- [ ] Выбор объекта (модалка с поиском, сброс ×)
|
||||
- [ ] Таблица: чекбокс, #, Наименование (+ бейдж объекта, «✓ импортировано», подзаголовок), Код СНБ, Ед. изм., Объём
|
||||
- [ ] Поиск с debounce 250 мс по всем полям; пагинация по 100
|
||||
- [ ] «Выделить всё на странице» / «Снять все на странице» (фактически — все отфильтрованные)
|
||||
- [ ] Уже импортированные — неактивны, «Уже импортировано»
|
||||
- [ ] «Импортировать» (нужен объект, иначе тост) и «Импорт с созданием объекта» (поиск/создание объекта и раздела по названиям из сметы)
|
||||
- [ ] Подсказка «Объекты из сметы:» (3 строки + «ещё N»)
|
||||
- [ ] Тосты «✓ Успешно импортировано: N» / «Импортировано: N, ошибок: M»
|
||||
- [ ] Защита от дублей по `cree9_comment` = id работы из сметы
|
||||
|
||||
🐞 Пагинация в модалке объектов не выводится (видны только первые 100).
|
||||
|
||||
---
|
||||
|
||||
## 7. Ресурсы проекта (stroy-bot-project-assigner) — `?page=resources&projectId=`
|
||||
|
||||
- [ ] Нет projectId — «Проект не найден. Откройте страницу через список проектов.»
|
||||
- [ ] Вкладки «Профессии (N)» и «Компании (N)»
|
||||
- [ ] Слева «Доступные»: поиск, очистка ✕, «уже добавлено», «+», «Ничего не найдено» / «Нет элементов»
|
||||
- [ ] Справа «Привязаны к проекту»: «−», «Ничего не добавлено»
|
||||
- [ ] Оптимистичное добавление / удаление с откатом и ошибкой
|
||||
- [ ] На узких экранах колонки друг под другом
|
||||
|
||||
---
|
||||
|
||||
## 8. Привязка к справочнику (stroy-bot-projects-binder) — `?page=binder&projectId=`
|
||||
|
||||
- [ ] Карточка проекта: название, Описание, Адрес, Статус (цвет), Начало, Окончание, Координаты
|
||||
- [ ] Бейдж «Привязан» / «Не привязан», `UniqueName · UniqueId0`, «Отвязать»
|
||||
- [ ] Поиск по справочнику (debounce 400 мс, пустой — все), пагинация ← / →, «{page} / {total} · всего: N»
|
||||
- [ ] Карточки результатов: name, Код, Описание, Адрес, Координаты; выбор / снятие выбора; «Связать»
|
||||
- [ ] Сообщения успеха и ошибок
|
||||
- [ ] Запись `UniqueId0` и `UniqueName` в «Проекты»
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Схема списка **«Доступы»**: в репозиториях встречаются и поля `projectId`/`userId`, и lookup `user`/`project`. Проверить на сайте.
|
||||
- Схема списка **«Объекты»**: родитель читается то как `parentId`, то как `parent/Id`. Проверить на сайте.
|
||||
Generated
+2
@@ -11,6 +11,8 @@
|
||||
"@fluentui/react": "^8.106.4",
|
||||
"@microsoft/sp-component-base": "1.22.1",
|
||||
"@microsoft/sp-core-library": "1.22.1",
|
||||
"@microsoft/sp-http": "1.22.1",
|
||||
"@microsoft/sp-loader": "1.22.1",
|
||||
"@microsoft/sp-lodash-subset": "1.22.1",
|
||||
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
||||
"@microsoft/sp-property-pane": "1.22.1",
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"@fluentui/react": "^8.106.4",
|
||||
"@microsoft/sp-component-base": "1.22.1",
|
||||
"@microsoft/sp-core-library": "1.22.1",
|
||||
"@microsoft/sp-http": "1.22.1",
|
||||
"@microsoft/sp-loader": "1.22.1",
|
||||
"@microsoft/sp-lodash-subset": "1.22.1",
|
||||
"@microsoft/sp-office-ui-fabric-core": "1.22.1",
|
||||
"@microsoft/sp-property-pane": "1.22.1",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// ============================================
|
||||
// ЗЕРКАЛО токенов пакета @tnsu/ui-kit (src/tokens/_tokens.scss).
|
||||
// Источник правды — ui-kit. Локальная копия нужна потому, что heft не резолвит
|
||||
// scss из node_modules через ~. При изменении токенов в ui-kit — синхронизировать.
|
||||
// Заменяет 8 копий design-tokens-v5.scss из старых репозиториев stroy-bot-*.
|
||||
// ============================================
|
||||
|
||||
// ---- Navy (Primary) ----
|
||||
$navy-950: #0A1B30;
|
||||
$navy-900: #0F2440;
|
||||
$navy-800: #1B3A5C;
|
||||
$navy-700: #244B73;
|
||||
$navy-600: #2D5C8A;
|
||||
$navy-100: #E8EEF4;
|
||||
$navy-50: #F4F7FA;
|
||||
|
||||
// ---- Gold (Accent) ----
|
||||
$gold-600: #B8881A;
|
||||
$gold-500: #D4A01E;
|
||||
$gold-400: #E5B635;
|
||||
$gold-300: #F0CC5E;
|
||||
$gold-100: #FDF5E0;
|
||||
$gold-50: #FEFBF0;
|
||||
|
||||
// ---- Gray (Neutral) ----
|
||||
$gray-900: #111827;
|
||||
$gray-700: #374151;
|
||||
$gray-500: #6B7280;
|
||||
$gray-400: #9CA3AF;
|
||||
$gray-300: #D1D5DB;
|
||||
$gray-200: #E5E7EB;
|
||||
$gray-100: #F3F4F6;
|
||||
$gray-50: #F9FAFB;
|
||||
|
||||
// ---- Semantic ----
|
||||
$red-500: #DC2626;
|
||||
$red-50: #FEF2F2;
|
||||
$green-600: #15803D;
|
||||
$green-50: #F0FDF4;
|
||||
$blue-600: #2563EB;
|
||||
$blue-50: #EFF6FF;
|
||||
$amber-700: #854D0E;
|
||||
$amber-50: #FBF1D9; // светлый янтарный фон предупреждений (пара к $amber-700)
|
||||
|
||||
$white: #fff;
|
||||
|
||||
// ---- Typography ----
|
||||
// Заголовки страниц и секций — Lora (serif). Остальной текст — Inter (sans).
|
||||
$font-serif: 'Lora', Georgia, serif;
|
||||
$font-sans: 'Inter', -apple-system, 'Segoe UI', sans-serif;
|
||||
|
||||
$fs-page-title: 24px; // Lora 600
|
||||
$fs-form-title: 22px; // Lora 600
|
||||
$fs-section-title: 15px; // Lora 600
|
||||
$fs-body: 14px; // Inter 400
|
||||
$fs-sm: 13px;
|
||||
$fs-label: 12px; // Inter 500
|
||||
$fs-caption: 11px;
|
||||
$fs-overline: 10.5px; // uppercase, letter-spacing 1.4-1.8px
|
||||
|
||||
// ---- Radius ----
|
||||
$r-sm: 6px;
|
||||
$r-md: 10px;
|
||||
$r-lg: 14px;
|
||||
$r-full: 100px;
|
||||
|
||||
// ---- Shadows ----
|
||||
$sh-1: 0 1px 2px rgba(15,36,64,.04), 0 1px 3px rgba(15,36,64,.06);
|
||||
$sh-2: 0 4px 16px rgba(15,36,64,.07);
|
||||
$sh-3: 0 12px 32px rgba(15,36,64,.10);
|
||||
$sh-gold: 0 4px 20px rgba(212,160,30,.18);
|
||||
$sh-modal: 0 24px 70px rgba(10,27,48,.25);
|
||||
|
||||
// ---- Gradients (фирменные) ----
|
||||
$grad-header: linear-gradient(135deg, $navy-950 0%, $navy-800 85%);
|
||||
$grad-gold: linear-gradient(135deg, $gold-400, $gold-500);
|
||||
$grad-gold-v: linear-gradient(180deg, $gold-400, $gold-600);
|
||||
$grad-hairline: linear-gradient(90deg, $gold-400, transparent 70%);
|
||||
|
||||
// ---- Spacing ----
|
||||
$sp-1: 4px; $sp-2: 8px; $sp-3: 12px;
|
||||
$sp-4: 16px; $sp-5: 20px; $sp-6: 24px;
|
||||
$sp-7: 28px; $sp-8: 32px;
|
||||
|
||||
// ---- Готовые миксины ----
|
||||
@mixin page-header {
|
||||
background: $grad-header;
|
||||
border-radius: $r-lg;
|
||||
padding: 26px 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, $gold-500, transparent 60%);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin card-hover-gold {
|
||||
transition: .22s;
|
||||
&:hover {
|
||||
box-shadow: $sh-3;
|
||||
transform: translateY(-2px);
|
||||
border-color: $gold-300;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin btn-accent {
|
||||
background: $grad-gold;
|
||||
color: $navy-950;
|
||||
font-weight: 600;
|
||||
border: 1px solid $gold-500;
|
||||
&:hover { box-shadow: $sh-gold; transform: translateY(-1px); }
|
||||
}
|
||||
|
||||
@mixin input-focus-gold {
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-color: $gold-500;
|
||||
box-shadow: 0 0 0 3px rgba(212,160,30,.12);
|
||||
}
|
||||
}
|
||||
|
||||
// Вид поля «только чтение» (режим согласующего / отправленная заявка):
|
||||
// приглушённый фон, серый текст, без золотого фокуса — визуально «неактивно».
|
||||
// $sel — псевдокласс состояния поля (:read-only для input/textarea, :disabled для select).
|
||||
@mixin readonly-field($sel) {
|
||||
&#{$sel},
|
||||
&#{$sel}:focus {
|
||||
background: $gray-50;
|
||||
color: $gray-500;
|
||||
border-color: $gray-200;
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin hairline {
|
||||
height: 1px;
|
||||
background: $grad-hairline;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
// ---- Брейкпоинты (адаптивность) ----
|
||||
// Единые точки перелома для ВСЕХ компонентов ui-kit. Используй миксин below():
|
||||
// @include below($bp-md) { ... правила для экранов уже 768px ... }
|
||||
$bp-xs: 350px; // очень маленькая мобилка (страховочный минимум)
|
||||
$bp-sm: 640px; // крупный телефон
|
||||
$bp-md: 768px; // планшет / граница «мобилка ↔ десктоп»
|
||||
$bp-lg: 1024px; // небольшой ноутбук
|
||||
$bp-xl: 1280px; // широкий монитор
|
||||
|
||||
@mixin below($bp) {
|
||||
@media (max-width: $bp) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin above($bp) {
|
||||
@media (min-width: ($bp + 1)) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
|
||||
"id": "619a5eba-d596-47e5-a95a-9b9682ed7ec3",
|
||||
"alias": "StroyBotWebPart",
|
||||
"componentType": "WebPart",
|
||||
|
||||
// The "*" signifies that the version should be taken from the package.json
|
||||
"version": "*",
|
||||
"manifestVersion": 2,
|
||||
|
||||
"requiresCustomScript": false,
|
||||
"supportedHosts": ["SharePointWebPart", "SharePointFullPage"],
|
||||
"supportsThemeVariants": true,
|
||||
|
||||
"preconfiguredEntries": [
|
||||
{
|
||||
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
|
||||
"group": { "default": "Advanced" },
|
||||
"title": { "default": "StroyBot" },
|
||||
"description": { "default": "StroyBot: проекты, объекты, работы и отчёты" },
|
||||
"officeFabricIconFontName": "ConstructionCone",
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { Version } from '@microsoft/sp-core-library';
|
||||
import { IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane';
|
||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||
import { SPComponentLoader } from '@microsoft/sp-loader';
|
||||
import { App } from './app/App';
|
||||
import { createAppServices, IConnectionSettings } from './services/AppServices';
|
||||
import { FONTS_CSS_URL } from './config/constants';
|
||||
import { PROPERTY_PANE_TEXTS } from './app/texts';
|
||||
|
||||
export interface IStroyBotWebPartProps extends IConnectionSettings {}
|
||||
|
||||
export default class StroyBotWebPart extends BaseClientSideWebPart<IStroyBotWebPartProps> {
|
||||
protected async onInit(): Promise<void> {
|
||||
// Inter и Lora — шрифты дизайн-системы. Раньше их грузили report-list и assigner,
|
||||
// остальные web parts полагались на шрифты, установленные у пользователя.
|
||||
SPComponentLoader.loadCss(FONTS_CSS_URL);
|
||||
await super.onInit();
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
const services = createAppServices(this.context, this.properties);
|
||||
ReactDom.render(React.createElement(App, { services }), this.domElement);
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
ReactDom.unmountComponentAtNode(this.domElement);
|
||||
}
|
||||
|
||||
protected get dataVersion(): Version {
|
||||
return Version.parse('1.0');
|
||||
}
|
||||
|
||||
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
|
||||
const texts = PROPERTY_PANE_TEXTS;
|
||||
return {
|
||||
pages: [
|
||||
{
|
||||
header: { description: texts.header },
|
||||
groups: [
|
||||
{
|
||||
groupName: texts.connectionGroup,
|
||||
groupFields: [
|
||||
PropertyPaneTextField('siteUrl', { label: texts.siteUrl, placeholder: texts.siteUrlPlaceholder }),
|
||||
PropertyPaneTextField('dataverseUrl', {
|
||||
label: texts.dataverseUrl,
|
||||
placeholder: texts.dataverseUrlPlaceholder
|
||||
}),
|
||||
PropertyPaneTextField('apiBaseUrl', { label: texts.apiBaseUrl, placeholder: texts.apiBaseUrlPlaceholder })
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import '@tnsu/ui-kit/styles.css';
|
||||
import { ConfirmHost, IconSprite } from '@tnsu/ui-kit';
|
||||
import { IAppServices } from '../services/AppServices';
|
||||
import { AppServicesContext } from './AppServicesContext';
|
||||
import { RouterProvider } from './routing/RouterContext';
|
||||
import { AppShell } from './AppShell';
|
||||
|
||||
interface Props {
|
||||
services: IAppServices;
|
||||
}
|
||||
|
||||
/** Корень приложения: стили и общие хосты ui-kit (спрайт иконок, модалка подтверждения) монтируются один раз. */
|
||||
export const App: React.FC<Props> = ({ services }) => (
|
||||
<AppServicesContext.Provider value={services}>
|
||||
<IconSprite />
|
||||
<ConfirmHost />
|
||||
<RouterProvider>
|
||||
<AppShell />
|
||||
</RouterProvider>
|
||||
</AppServicesContext.Provider>
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { IAppServices } from '../services/AppServices';
|
||||
|
||||
// Клиенты к SharePoint / Dataverse / API нужны почти каждой странице —
|
||||
// поэтому они в контексте, а не передаются пропсами через всё дерево.
|
||||
export const AppServicesContext = React.createContext<IAppServices | undefined>(undefined);
|
||||
|
||||
export function useAppServices(): IAppServices {
|
||||
const services = React.useContext(AppServicesContext);
|
||||
if (!services) {
|
||||
throw new Error('useAppServices: компонент отрисован вне AppServicesContext.Provider');
|
||||
}
|
||||
return services;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@import '../../../styles/design-tokens';
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
font-family: $font-sans;
|
||||
color: $gray-900;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react';
|
||||
import { AppLayout, Sidebar, SidebarToggle } from '@tnsu/ui-kit';
|
||||
import { useRouter } from './routing/RouterContext';
|
||||
import { PageId } from './routing/routes';
|
||||
import { buildSidebarItems, navPageFor } from './navigation';
|
||||
import { PageOutlet } from './PageOutlet';
|
||||
import { APP_TEXTS } from './texts';
|
||||
import styles from './AppShell.module.scss';
|
||||
|
||||
/** Каркас приложения: меню разделов слева, текущий раздел справа. */
|
||||
export const AppShell: React.FC = () => {
|
||||
const { route, navigate } = useRouter();
|
||||
const [isCollapsed, setIsCollapsed] = React.useState<boolean>(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState<boolean>(false);
|
||||
|
||||
const openSection = (id: string): void => {
|
||||
setIsMobileMenuOpen(false);
|
||||
navigate({ page: id as PageId, projectId: route.projectId });
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
sidebar={
|
||||
<Sidebar
|
||||
items={buildSidebarItems(route.projectId)}
|
||||
activeId={navPageFor(route.page)}
|
||||
onChange={openSection}
|
||||
brand={APP_TEXTS.brand}
|
||||
collapsed={isCollapsed}
|
||||
onToggleCollapsed={() => setIsCollapsed((prev) => !prev)}
|
||||
mobileOpen={isMobileMenuOpen}
|
||||
onMobileClose={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
}
|
||||
mobileToggle={<SidebarToggle onClick={() => setIsMobileMenuOpen(true)} />}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<PageOutlet />
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { useRouter } from './routing/RouterContext';
|
||||
import { SectionPlaceholder } from './SectionPlaceholder';
|
||||
|
||||
/** Показывает страницу текущего раздела. Разделы подключаются сюда по мере переноса. */
|
||||
export const PageOutlet: React.FC = () => {
|
||||
const { route } = useRouter();
|
||||
|
||||
switch (route.page) {
|
||||
default:
|
||||
return <SectionPlaceholder />;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { EmptyState } from '../shared/components/EmptyState';
|
||||
import { APP_TEXTS } from './texts';
|
||||
|
||||
/** Временная заглушка для разделов, которые ещё не перенесены. Удаляется вместе с последним разделом. */
|
||||
export const SectionPlaceholder: React.FC = () => (
|
||||
<EmptyState icon="refresh" title={APP_TEXTS.sectionInProgress} text={APP_TEXTS.sectionInProgressHint} />
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { buildSidebarItems, navPageFor } from './navigation';
|
||||
|
||||
describe('buildSidebarItems', () => {
|
||||
it('без проекта в меню только главная', () => {
|
||||
expect(buildSidebarItems(undefined).map((item) => item.id)).toEqual(['hub']);
|
||||
});
|
||||
|
||||
it('с проектом — все разделы', () => {
|
||||
expect(buildSidebarItems('5').map((item) => item.id)).toEqual([
|
||||
'hub',
|
||||
'objects',
|
||||
'reports',
|
||||
'report',
|
||||
'import',
|
||||
'resources',
|
||||
'binder'
|
||||
]);
|
||||
});
|
||||
|
||||
it('недоступные разделы скрываются, главная остаётся всегда', () => {
|
||||
const onlyReports = (page: string): boolean => page === 'reports';
|
||||
expect(buildSidebarItems('5', onlyReports).map((item) => item.id)).toEqual(['hub', 'reports']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('navPageFor', () => {
|
||||
it('работы подсвечивают пункт «Объекты»', () => {
|
||||
expect(navPageFor('works')).toEqual('objects');
|
||||
});
|
||||
|
||||
it('остальные разделы подсвечивают сами себя', () => {
|
||||
expect(navPageFor('reports')).toEqual('reports');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IconName, ISidebarItem } from '@tnsu/ui-kit';
|
||||
import { PageId } from './routing/routes';
|
||||
|
||||
export interface INavItem {
|
||||
page: PageId;
|
||||
title: string;
|
||||
icon: IconName;
|
||||
/** Раздел работает только внутри проекта (нужен projectId). */
|
||||
needsProject: boolean;
|
||||
}
|
||||
|
||||
// Названия разделов — как на карточках stroy-bot-main.
|
||||
export const NAV_ITEMS: INavItem[] = [
|
||||
{ page: 'hub', title: 'Проект', icon: 'building', needsProject: false },
|
||||
{ page: 'objects', title: 'Объекты', icon: 'folder', needsProject: true },
|
||||
{ page: 'reports', title: 'Отчёты по проекту', icon: 'clipboard', needsProject: true },
|
||||
{ page: 'report', title: 'Отправить отчёт', icon: 'send', needsProject: true },
|
||||
{ page: 'import', title: 'Загрузить работы из сметы', icon: 'download', needsProject: true },
|
||||
{ page: 'resources', title: 'Ресурсы проекта', icon: 'people', needsProject: true },
|
||||
{ page: 'binder', title: 'Привязка к справочнику', icon: 'swap', needsProject: true }
|
||||
];
|
||||
|
||||
/** Пункт меню, который подсвечивается для раздела. Работы — подраздел объектов, форма отчёта — свой пункт. */
|
||||
export function navPageFor(page: PageId): PageId {
|
||||
return page === 'works' ? 'objects' : page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пункты меню для текущего состояния. Без проекта — только главная.
|
||||
* isAvailable — правила доступа к разделу (статус проекта, права); по умолчанию всё доступно.
|
||||
*/
|
||||
export function buildSidebarItems(
|
||||
projectId: string | undefined,
|
||||
isAvailable: (page: PageId) => boolean = () => true
|
||||
): ISidebarItem[] {
|
||||
return NAV_ITEMS.filter((item) => !item.needsProject || (projectId !== undefined && isAvailable(item.page))).map(
|
||||
(item) => ({ id: item.page, title: item.title, icon: item.icon })
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import { IRoute } from './routes';
|
||||
import { useRouter } from './RouterContext';
|
||||
|
||||
interface Props {
|
||||
to: IRoute;
|
||||
className?: string;
|
||||
/** Неактивная ссылка: рендерится без href и не реагирует на клик. */
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ссылка на раздел приложения. Это настоящий <a href>, поэтому работают Ctrl+клик,
|
||||
* средняя кнопка и «открыть в новой вкладке» (как было в stroy-bot-main),
|
||||
* а обычный клик переключает раздел без перезагрузки страницы.
|
||||
*/
|
||||
export const RouteLink: React.FC<Props> = ({ to, className, disabled, children }) => {
|
||||
const { navigate, buildHref } = useRouter();
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<a className={className} aria-disabled="true">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const onClick = (event: React.MouseEvent<HTMLAnchorElement>): void => {
|
||||
const opensNewTab = event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0;
|
||||
if (opensNewTab) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
return (
|
||||
<a className={className} href={buildHref(to)} onClick={onClick}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { buildSearch, IRoute, parseRoute } from './routes';
|
||||
|
||||
interface IRouterValue {
|
||||
route: IRoute;
|
||||
/** Перейти в раздел: меняет адрес (history.pushState) и перерисовывает приложение. */
|
||||
navigate: (route: IRoute) => void;
|
||||
/** Полный адрес раздела — для настоящих ссылок (Ctrl+клик, «открыть в новой вкладке»). */
|
||||
buildHref: (route: IRoute) => string;
|
||||
}
|
||||
|
||||
const RouterContext = React.createContext<IRouterValue | undefined>(undefined);
|
||||
|
||||
function readCurrentRoute(): IRoute {
|
||||
return parseRoute(window.location.search);
|
||||
}
|
||||
|
||||
function buildHref(route: IRoute): string {
|
||||
return `${window.location.pathname}${buildSearch(route, window.location.search)}`;
|
||||
}
|
||||
|
||||
export const RouterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [route, setRoute] = React.useState<IRoute>(readCurrentRoute);
|
||||
|
||||
// Кнопки «назад/вперёд» браузера меняют адрес без нашего участия — перечитываем маршрут.
|
||||
React.useEffect(() => {
|
||||
const onPopState = (): void => setRoute(readCurrentRoute());
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, []);
|
||||
|
||||
const navigate = React.useCallback((next: IRoute): void => {
|
||||
window.history.pushState(undefined, '', buildHref(next));
|
||||
setRoute(next);
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo<IRouterValue>(() => ({ route, navigate, buildHref }), [route, navigate]);
|
||||
|
||||
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
|
||||
};
|
||||
|
||||
export function useRouter(): IRouterValue {
|
||||
const router = React.useContext(RouterContext);
|
||||
if (!router) {
|
||||
throw new Error('useRouter: компонент отрисован вне RouterProvider');
|
||||
}
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { parseRoute, buildSearch } from './routes';
|
||||
|
||||
describe('parseRoute', () => {
|
||||
it('пустой адрес - главная', () => {
|
||||
expect(parseRoute('')).toEqual({ page: 'hub' });
|
||||
});
|
||||
it('адрес с разделом works, проектом 3 и объектом 41', () => {
|
||||
expect(parseRoute('?page=works&projectId=3&objectId=41')).toEqual({
|
||||
page: 'works',
|
||||
projectId: '3',
|
||||
objectId: '41'
|
||||
});
|
||||
});
|
||||
it('опечатка в разделе (objectz) — должна быть главная, а projectId сохраниться', () => {
|
||||
expect(parseRoute('?page=objectz&projectId=3')).toEqual({ page: 'hub', projectId: '3' });
|
||||
});
|
||||
it('адрес только с projectId=7', () => {
|
||||
expect(parseRoute('?projectId=7')).toEqual({ page: 'hub', projectId: '7' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// Маршруты приложения. Раздел и его параметры живут в query-строке страницы
|
||||
// SharePoint (?page=objects&projectId=5), поэтому ссылку на любой раздел можно
|
||||
// скопировать, открыть в новой вкладке, а «назад» в браузере работает как раньше.
|
||||
|
||||
export const PAGE_IDS = ['hub', 'objects', 'works', 'reports', 'report', 'import', 'resources', 'binder'] as const;
|
||||
|
||||
export type PageId = (typeof PAGE_IDS)[number];
|
||||
|
||||
/** Параметры маршрута. Все id — строки как в URL; разбор в числа делают страницы. */
|
||||
export interface IRoute {
|
||||
page: PageId;
|
||||
projectId?: string;
|
||||
/** Работы: id элемента списка «Объекты». */
|
||||
objectId?: string;
|
||||
/** Форма отчёта: открыть существующий отчёт (GUID Dataverse). */
|
||||
reportId?: string;
|
||||
/** Форма отчёта: создать на основе существующего (GUID Dataverse). */
|
||||
copyFromId?: string;
|
||||
}
|
||||
|
||||
export const ROUTE_PARAMS = {
|
||||
page: 'page',
|
||||
projectId: 'projectId',
|
||||
objectId: 'objectId',
|
||||
reportId: 'reportId',
|
||||
copyFromId: 'copyFromId'
|
||||
} as const;
|
||||
|
||||
const ROUTE_VALUE_KEYS: Array<Exclude<keyof IRoute, 'page'>> = ['projectId', 'objectId', 'reportId', 'copyFromId'];
|
||||
|
||||
function isPageId(value: string | undefined): value is PageId {
|
||||
return value !== undefined && (PAGE_IDS as readonly string[]).indexOf(value) !== -1;
|
||||
}
|
||||
|
||||
export function parseRoute(search: string): IRoute {
|
||||
const params = new URLSearchParams(search);
|
||||
const page = params.get(ROUTE_PARAMS.page) ?? undefined;
|
||||
const route: IRoute = { page: isPageId(page) ? page : 'hub' };
|
||||
|
||||
ROUTE_VALUE_KEYS.forEach((key) => {
|
||||
const value = params.get(ROUTE_PARAMS[key]);
|
||||
if (value) {
|
||||
route[key] = value;
|
||||
}
|
||||
});
|
||||
return route;
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает query-строку для маршрута. Посторонние параметры страницы SharePoint
|
||||
* (например, режим редактирования) сохраняются, параметры прошлого маршрута — нет.
|
||||
*/
|
||||
export function buildSearch(route: IRoute, currentSearch: string): string {
|
||||
const params = new URLSearchParams(currentSearch);
|
||||
params.delete(ROUTE_PARAMS.page);
|
||||
ROUTE_VALUE_KEYS.forEach((key) => params.delete(ROUTE_PARAMS[key]));
|
||||
|
||||
if (route.page !== 'hub') {
|
||||
params.set(ROUTE_PARAMS.page, route.page);
|
||||
}
|
||||
ROUTE_VALUE_KEYS.forEach((key) => {
|
||||
const value = route[key];
|
||||
if (value) {
|
||||
params.set(ROUTE_PARAMS[key], value);
|
||||
}
|
||||
});
|
||||
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Тексты оболочки приложения. Тексты разделов лежат рядом с разделами (pages/*/texts.ts).
|
||||
|
||||
export const APP_TEXTS = {
|
||||
brand: 'StroyBot',
|
||||
sectionInProgress: 'Раздел переносится из старого web part',
|
||||
sectionInProgressHint: 'Пока пользуйтесь старой страницей раздела.'
|
||||
} as const;
|
||||
|
||||
export const PROPERTY_PANE_TEXTS = {
|
||||
header: 'Настройки StroyBot',
|
||||
connectionGroup: 'Подключение',
|
||||
siteUrl: 'URL сайта SharePoint со списками (пусто — по умолчанию)',
|
||||
siteUrlPlaceholder: 'https://tnsukz.sharepoint.com/sites/stroy',
|
||||
dataverseUrl: 'URL Dataverse (пусто — по умолчанию)',
|
||||
dataverseUrlPlaceholder: 'https://tansu.crm5.dynamics.com',
|
||||
apiBaseUrl: 'Базовый URL API (пусто — по умолчанию)',
|
||||
apiBaseUrlPlaceholder: 'https://api.tnsu.kz'
|
||||
} as const;
|
||||
@@ -0,0 +1,23 @@
|
||||
// Адреса окружения StroyBot. Раньше каждое значение было захардкожено
|
||||
// в нескольких репозиториях stroy-bot-* — теперь единственное место.
|
||||
|
||||
/** Сайт SharePoint со списками StroyBot (Проекты, Объекты, Доступы и т.д.). */
|
||||
export const SITE_URL = 'https://tnsukz.sharepoint.com/sites/stroy';
|
||||
|
||||
/** Окружение Dataverse (таблицы cree9_*). */
|
||||
export const DATAVERSE_URL = 'https://tansu.crm5.dynamics.com';
|
||||
|
||||
/** Корпоративный API (справочник проектов, сметы, Excel-выгрузки). */
|
||||
export const TNSU_API_BASE_URL = 'https://api.tnsu.kz';
|
||||
|
||||
/** AAD-приложение корпоративного API — ресурс для AadHttpClient. */
|
||||
export const TNSU_API_RESOURCE_ID = '9232d9e2-9807-4753-808f-07250e5474b0';
|
||||
|
||||
/** Шрифты дизайн-системы (Inter — текст, Lora — заголовки). Тот же URL, что в report-list и assigner. */
|
||||
export const FONTS_CSS_URL =
|
||||
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@500;600;700&display=swap';
|
||||
|
||||
/** Ключи sessionStorage. Значение совпадает со stroy-bot-main — сохранённый выбор не теряется. */
|
||||
export const SESSION_KEYS = {
|
||||
selectedProject: 'stroybot_selected_project'
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
// Таблицы Dataverse StroyBot (Web API, множественное число — как в URL).
|
||||
|
||||
export const DATAVERSE_API_PATH = '/api/data/v9.2/';
|
||||
|
||||
export const DV_TABLES = {
|
||||
/** Работы (иерархия, привязка к объекту SharePoint через cree9_objectid). */
|
||||
works: 'cree9_tables',
|
||||
/** Отчёты. */
|
||||
reports: 'cree9_reports',
|
||||
/** Строки отчётов: работы, персонал, техника. */
|
||||
reportData: 'cree9_report_datas'
|
||||
} as const;
|
||||
|
||||
/** Логическое имя (единственное число) таблицы работ — для EntityDefinitions. */
|
||||
export const DV_WORKS_LOGICAL_NAME = 'cree9_table';
|
||||
@@ -0,0 +1,23 @@
|
||||
// Названия списков и библиотек SharePoint (getbytitle).
|
||||
// Внутренние имена полей описываются рядом с репозиторием, который с ними работает.
|
||||
|
||||
export const LISTS = {
|
||||
projects: 'Проекты',
|
||||
objects: 'Объекты',
|
||||
access: 'Доступы',
|
||||
companies: 'Компании',
|
||||
companiesByProject: 'Компании по проекту',
|
||||
professions: 'Профессии',
|
||||
professionsByProject: 'Профессии по проекту',
|
||||
shifts: 'Смены',
|
||||
technics: 'Техника',
|
||||
units: 'Единицы измерения'
|
||||
} as const;
|
||||
|
||||
/** Библиотека с фотографиями отчётов: папка `{projectId}_{reportNumber}`. */
|
||||
export const REPORT_PHOTO_LIBRARY = 'DocLib';
|
||||
|
||||
/** Библиотека с архивом проектов: папка `Архив/{название проекта}`. */
|
||||
export const ARCHIVE_FOLDER_PATH = 'Shared Documents/Архив';
|
||||
|
||||
export type ListTitle = (typeof LISTS)[keyof typeof LISTS];
|
||||
@@ -0,0 +1,70 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { IHttpClient } from './http/IHttpClient';
|
||||
import { createAadClient, createSharePointClient } from './http/spfxClients';
|
||||
import { DATAVERSE_URL, SITE_URL, TNSU_API_BASE_URL, TNSU_API_RESOURCE_ID } from '../config/constants';
|
||||
|
||||
/** Настройки подключения из property pane (пустое значение — берётся значение по умолчанию). */
|
||||
export interface IConnectionSettings {
|
||||
siteUrl?: string;
|
||||
dataverseUrl?: string;
|
||||
apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
export interface IResolvedSettings {
|
||||
siteUrl: string;
|
||||
dataverseUrl: string;
|
||||
apiBaseUrl: string;
|
||||
}
|
||||
|
||||
/** Сведения о текущей странице и пользователе из контекста SPFx. */
|
||||
export interface IPageInfo {
|
||||
/** Абсолютный URL сайта, на котором стоит web part. */
|
||||
webAbsoluteUrl: string;
|
||||
/** Серверный относительный URL сайта (`/sites/stroy`) — нужен для путей к папкам. */
|
||||
webServerRelativeUrl: string;
|
||||
/** Id пользователя в SharePoint этого сайта (legacyPageContext.userId). */
|
||||
userId: number | undefined;
|
||||
userDisplayName: string;
|
||||
}
|
||||
|
||||
/** Всё, что нужно страницам для работы с данными. Создаётся в web part. */
|
||||
export interface IAppServices {
|
||||
sharePoint: IHttpClient;
|
||||
dataverse: IHttpClient;
|
||||
tnsuApi: IHttpClient;
|
||||
settings: IResolvedSettings;
|
||||
page: IPageInfo;
|
||||
}
|
||||
|
||||
function withoutTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function resolveSettings(settings: IConnectionSettings): IResolvedSettings {
|
||||
return {
|
||||
siteUrl: withoutTrailingSlash(settings.siteUrl || SITE_URL),
|
||||
dataverseUrl: withoutTrailingSlash(settings.dataverseUrl || DATAVERSE_URL),
|
||||
apiBaseUrl: withoutTrailingSlash(settings.apiBaseUrl || TNSU_API_BASE_URL)
|
||||
};
|
||||
}
|
||||
|
||||
function readPageInfo(context: WebPartContext): IPageInfo {
|
||||
const legacyUserId: unknown = context.pageContext.legacyPageContext?.userId;
|
||||
return {
|
||||
webAbsoluteUrl: context.pageContext.web.absoluteUrl,
|
||||
webServerRelativeUrl: context.pageContext.web.serverRelativeUrl,
|
||||
userId: typeof legacyUserId === 'number' ? legacyUserId : undefined,
|
||||
userDisplayName: context.pageContext.user.displayName
|
||||
};
|
||||
}
|
||||
|
||||
export function createAppServices(context: WebPartContext, settings: IConnectionSettings): IAppServices {
|
||||
const resolved = resolveSettings(settings);
|
||||
return {
|
||||
sharePoint: createSharePointClient(context),
|
||||
dataverse: createAadClient(context, resolved.dataverseUrl),
|
||||
tnsuApi: createAadClient(context, TNSU_API_RESOURCE_ID),
|
||||
settings: resolved,
|
||||
page: readPageInfo(context)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Минимальный контракт HTTP-клиента. Репозитории зависят только от него,
|
||||
// а не от SPHttpClient/AadHttpClient напрямую — поэтому в тестах их можно
|
||||
// проверить заглушкой и убедиться, что URL, фильтры и тела запросов 1:1
|
||||
// совпадают со старыми web parts.
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
|
||||
export interface IHttpRequest {
|
||||
method?: HttpMethod;
|
||||
headers?: Record<string, string>;
|
||||
body?: string | ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface IHttpResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText: string;
|
||||
json(): Promise<unknown>;
|
||||
text(): Promise<string>;
|
||||
blob(): Promise<Blob>;
|
||||
}
|
||||
|
||||
export interface IHttpClient {
|
||||
fetch(url: string, request?: IHttpRequest): Promise<IHttpResponse>;
|
||||
}
|
||||
|
||||
/** Ошибка HTTP-ответа: сохраняет статус, чтобы страницы показывали те же тексты, что и раньше. */
|
||||
export class HttpError extends Error {
|
||||
public readonly status: number;
|
||||
public readonly statusText: string;
|
||||
|
||||
constructor(status: number, statusText: string, details?: string) {
|
||||
super(details ? `${status} ${statusText}: ${details}` : `${status} ${statusText}`);
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
}
|
||||
}
|
||||
|
||||
/** Бросает HttpError, если ответ не успешный. Текст ответа добавляется в сообщение. */
|
||||
export async function ensureOk(response: IHttpResponse): Promise<IHttpResponse> {
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
let details = '';
|
||||
try {
|
||||
details = await response.text();
|
||||
} catch {
|
||||
// Тело ошибки может быть недоступно — статуса достаточно.
|
||||
}
|
||||
throw new HttpError(response.status, response.statusText, details);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { SPHttpClient, AadHttpClient } from '@microsoft/sp-http';
|
||||
import { IHttpClient, IHttpRequest, IHttpResponse } from './IHttpClient';
|
||||
|
||||
// Адаптеры SPFx-клиентов к IHttpClient. Вся работа с авторизацией — здесь,
|
||||
// остальной код о SPFx не знает.
|
||||
|
||||
export function createSharePointClient(context: WebPartContext): IHttpClient {
|
||||
return {
|
||||
fetch: (url: string, request: IHttpRequest = {}): Promise<IHttpResponse> =>
|
||||
context.spHttpClient.fetch(url, SPHttpClient.configurations.v1, {
|
||||
method: request.method ?? 'GET',
|
||||
headers: request.headers,
|
||||
body: request.body
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Клиент к ресурсу под AAD (Dataverse или корпоративный API).
|
||||
* AadHttpClient создаётся лениво и один раз — получение клиента запрашивает токен.
|
||||
*/
|
||||
export function createAadClient(context: WebPartContext, resource: string): IHttpClient {
|
||||
let clientPromise: Promise<AadHttpClient> | undefined;
|
||||
|
||||
const getClient = (): Promise<AadHttpClient> => {
|
||||
if (!clientPromise) {
|
||||
clientPromise = context.aadHttpClientFactory.getClient(resource);
|
||||
}
|
||||
return clientPromise;
|
||||
};
|
||||
|
||||
return {
|
||||
fetch: async (url: string, request: IHttpRequest = {}): Promise<IHttpResponse> => {
|
||||
const client = await getClient();
|
||||
return client.fetch(url, AadHttpClient.configurations.v1, {
|
||||
method: request.method ?? 'GET',
|
||||
headers: request.headers,
|
||||
body: request.body
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
padding: $sp-8 $sp-4;
|
||||
text-align: center;
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: $r-full;
|
||||
background: $navy-50;
|
||||
color: $navy-700;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-body;
|
||||
font-weight: 600;
|
||||
color: $navy-900;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0;
|
||||
font-size: $fs-sm;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: $sp-2;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import { Icon, IconName } from '@tnsu/ui-kit';
|
||||
import styles from './EmptyState.module.scss';
|
||||
|
||||
interface Props {
|
||||
icon?: IconName;
|
||||
title: string;
|
||||
text?: string;
|
||||
/** Кнопка действия под текстом (например, «+ Добавить объект»). */
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
// В ui-kit пока нет EmptyState — живёт здесь, позже переедет в кит.
|
||||
export const EmptyState: React.FC<Props> = ({ icon = 'info', title, text, action }) => (
|
||||
<div className={styles.emptyState}>
|
||||
<span className={styles.icon}>
|
||||
<Icon name={icon} size="lg" />
|
||||
</span>
|
||||
<p className={styles.title}>{title}</p>
|
||||
{text && <p className={styles.text}>{text}</p>}
|
||||
{action && <div className={styles.action}>{action}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
|
||||
"id": "619a5eba-d596-47e5-a95a-9b9682ed7ec3",
|
||||
"alias": "StroyBotMonorepoWebPart",
|
||||
"componentType": "WebPart",
|
||||
|
||||
// The "*" signifies that the version should be taken from the package.json
|
||||
"version": "*",
|
||||
"manifestVersion": 2,
|
||||
|
||||
// If true, the component can only be installed on sites where Custom Script is allowed.
|
||||
// Components that allow authors to embed arbitrary script code should set this to true.
|
||||
// https://support.office.com/en-us/article/Turn-scripting-capabilities-on-or-off-1f2c515f-5d7e-448a-9fd7-835da935584f
|
||||
"requiresCustomScript": false,
|
||||
"supportedHosts": ["SharePointWebPart", "TeamsPersonalApp", "TeamsTab", "SharePointFullPage"],
|
||||
"supportsThemeVariants": true,
|
||||
|
||||
"preconfiguredEntries": [{
|
||||
"groupId": "5c03119e-3074-46fd-976b-c60198311f70", // Advanced
|
||||
"group": { "default": "Advanced" },
|
||||
"title": { "default": "stroy-bot-monorepo" },
|
||||
"description": { "default": "stroy-bot-monorepo description" },
|
||||
"officeFabricIconFontName": "Page",
|
||||
"properties": {
|
||||
"description": "stroy-bot-monorepo"
|
||||
}
|
||||
}]
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { Version } from '@microsoft/sp-core-library';
|
||||
import {
|
||||
type IPropertyPaneConfiguration,
|
||||
PropertyPaneTextField
|
||||
} from '@microsoft/sp-property-pane';
|
||||
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
|
||||
import { IReadonlyTheme } from '@microsoft/sp-component-base';
|
||||
|
||||
import * as strings from 'StroyBotMonorepoWebPartStrings';
|
||||
import StroyBotMonorepo from './components/StroyBotMonorepo';
|
||||
import { IStroyBotMonorepoProps } from './components/IStroyBotMonorepoProps';
|
||||
|
||||
export interface IStroyBotMonorepoWebPartProps {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default class StroyBotMonorepoWebPart extends BaseClientSideWebPart<IStroyBotMonorepoWebPartProps> {
|
||||
|
||||
private _isDarkTheme: boolean = false;
|
||||
private _environmentMessage: string = '';
|
||||
|
||||
public render(): void {
|
||||
const element: React.ReactElement<IStroyBotMonorepoProps> = React.createElement(
|
||||
StroyBotMonorepo,
|
||||
{
|
||||
description: this.properties.description,
|
||||
isDarkTheme: this._isDarkTheme,
|
||||
environmentMessage: this._environmentMessage,
|
||||
hasTeamsContext: !!this.context.sdks.microsoftTeams,
|
||||
userDisplayName: this.context.pageContext.user.displayName
|
||||
}
|
||||
);
|
||||
|
||||
ReactDom.render(element, this.domElement);
|
||||
}
|
||||
|
||||
protected onInit(): Promise<void> {
|
||||
return this._getEnvironmentMessage().then(message => {
|
||||
this._environmentMessage = message;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
private _getEnvironmentMessage(): Promise<string> {
|
||||
if (!!this.context.sdks.microsoftTeams) { // running in Teams, office.com or Outlook
|
||||
return this.context.sdks.microsoftTeams.teamsJs.app.getContext()
|
||||
.then(context => {
|
||||
let environmentMessage: string = '';
|
||||
switch (context.app.host.name) {
|
||||
case 'Office': // running in Office
|
||||
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOffice : strings.AppOfficeEnvironment;
|
||||
break;
|
||||
case 'Outlook': // running in Outlook
|
||||
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentOutlook : strings.AppOutlookEnvironment;
|
||||
break;
|
||||
case 'Teams': // running in Teams
|
||||
case 'TeamsModern':
|
||||
environmentMessage = this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentTeams : strings.AppTeamsTabEnvironment;
|
||||
break;
|
||||
default:
|
||||
environmentMessage = strings.UnknownEnvironment;
|
||||
}
|
||||
|
||||
return environmentMessage;
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve(this.context.isServedFromLocalhost ? strings.AppLocalEnvironmentSharePoint : strings.AppSharePointEnvironment);
|
||||
}
|
||||
|
||||
protected onThemeChanged(currentTheme: IReadonlyTheme | undefined): void {
|
||||
if (!currentTheme) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isDarkTheme = !!currentTheme.isInverted;
|
||||
const {
|
||||
semanticColors
|
||||
} = currentTheme;
|
||||
|
||||
if (semanticColors) {
|
||||
this.domElement.style.setProperty('--bodyText', semanticColors.bodyText || null);
|
||||
this.domElement.style.setProperty('--link', semanticColors.link || null);
|
||||
this.domElement.style.setProperty('--linkHovered', semanticColors.linkHovered || null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
ReactDom.unmountComponentAtNode(this.domElement);
|
||||
}
|
||||
|
||||
protected get dataVersion(): Version {
|
||||
return Version.parse('1.0');
|
||||
}
|
||||
|
||||
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
|
||||
return {
|
||||
pages: [
|
||||
{
|
||||
header: {
|
||||
description: strings.PropertyPaneDescription
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
groupName: strings.BasicGroupName,
|
||||
groupFields: [
|
||||
PropertyPaneTextField('description', {
|
||||
label: strings.DescriptionFieldLabel
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,7 +0,0 @@
|
||||
export interface IStroyBotMonorepoProps {
|
||||
description: string;
|
||||
isDarkTheme: boolean;
|
||||
environmentMessage: string;
|
||||
hasTeamsContext: boolean;
|
||||
userDisplayName: string;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
@import '~@fluentui/react/dist/sass/References.scss';
|
||||
|
||||
.stroyBotMonorepo {
|
||||
overflow: hidden;
|
||||
padding: 1em;
|
||||
color: "[theme:bodyText, default: #323130]";
|
||||
color: var(--bodyText);
|
||||
&.teams {
|
||||
font-family: $ms-font-family-fallbacks;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcomeImage {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.links {
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: "[theme:link, default:#03787c]";
|
||||
color: var(--link); // note: CSS Custom Properties support is limited to modern browsers only
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
color: "[theme:linkHovered, default: #014446]";
|
||||
color: var(--linkHovered); // note: CSS Custom Properties support is limited to modern browsers only
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import styles from './StroyBotMonorepo.module.scss';
|
||||
import type { IStroyBotMonorepoProps } from './IStroyBotMonorepoProps';
|
||||
import { escape } from '@microsoft/sp-lodash-subset';
|
||||
|
||||
export default class StroyBotMonorepo extends React.Component<IStroyBotMonorepoProps> {
|
||||
public render(): React.ReactElement<IStroyBotMonorepoProps> {
|
||||
const {
|
||||
description,
|
||||
isDarkTheme,
|
||||
environmentMessage,
|
||||
hasTeamsContext,
|
||||
userDisplayName
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<section className={`${styles.stroyBotMonorepo} ${hasTeamsContext ? styles.teams : ''}`}>
|
||||
<div className={styles.welcome}>
|
||||
<img alt="" src={isDarkTheme ? require('../assets/welcome-dark.png') : require('../assets/welcome-light.png')} className={styles.welcomeImage} />
|
||||
<h2>Well done, {escape(userDisplayName)}!</h2>
|
||||
<div>{environmentMessage}</div>
|
||||
<div>Web part property value: <strong>{escape(description)}</strong></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Welcome to SharePoint Framework!</h3>
|
||||
<p>
|
||||
The SharePoint Framework (SPFx) is a extensibility model for Microsoft Viva, Microsoft Teams and SharePoint. It's the easiest way to extend Microsoft 365 with automatic Single Sign On, automatic hosting and industry standard tooling.
|
||||
</p>
|
||||
<h4>Learn more about SPFx development:</h4>
|
||||
<ul className={styles.links}>
|
||||
<li><a href="https://aka.ms/spfx" target="_blank" rel="noreferrer">SharePoint Framework Overview</a></li>
|
||||
<li><a href="https://aka.ms/spfx-yeoman-graph" target="_blank" rel="noreferrer">Use Microsoft Graph in your solution</a></li>
|
||||
<li><a href="https://aka.ms/spfx-yeoman-teams" target="_blank" rel="noreferrer">Build for Microsoft Teams using SharePoint Framework</a></li>
|
||||
<li><a href="https://aka.ms/spfx-yeoman-viva" target="_blank" rel="noreferrer">Build for Microsoft Viva Connections using SharePoint Framework</a></li>
|
||||
<li><a href="https://aka.ms/spfx-yeoman-store" target="_blank" rel="noreferrer">Publish SharePoint Framework applications to the marketplace</a></li>
|
||||
<li><a href="https://aka.ms/spfx-yeoman-api" target="_blank" rel="noreferrer">SharePoint Framework API reference</a></li>
|
||||
<li><a href="https://aka.ms/m365pnp" target="_blank" rel="noreferrer">Microsoft 365 Developer Community</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
define([], function() {
|
||||
return {
|
||||
"PropertyPaneDescription": "Description",
|
||||
"BasicGroupName": "Group Name",
|
||||
"DescriptionFieldLabel": "Description Field",
|
||||
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
|
||||
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
|
||||
"AppLocalEnvironmentOffice": "The app is running on your local environment in office.com",
|
||||
"AppLocalEnvironmentOutlook": "The app is running on your local environment in Outlook",
|
||||
"AppSharePointEnvironment": "The app is running on SharePoint page",
|
||||
"AppTeamsTabEnvironment": "The app is running in Microsoft Teams",
|
||||
"AppOfficeEnvironment": "The app is running in office.com",
|
||||
"AppOutlookEnvironment": "The app is running in Outlook",
|
||||
"UnknownEnvironment": "The app is running in an unknown environment"
|
||||
}
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
declare interface IStroyBotMonorepoWebPartStrings {
|
||||
PropertyPaneDescription: string;
|
||||
BasicGroupName: string;
|
||||
DescriptionFieldLabel: string;
|
||||
AppLocalEnvironmentSharePoint: string;
|
||||
AppLocalEnvironmentTeams: string;
|
||||
AppLocalEnvironmentOffice: string;
|
||||
AppLocalEnvironmentOutlook: string;
|
||||
AppSharePointEnvironment: string;
|
||||
AppTeamsTabEnvironment: string;
|
||||
AppOfficeEnvironment: string;
|
||||
AppOutlookEnvironment: string;
|
||||
UnknownEnvironment: string;
|
||||
}
|
||||
|
||||
declare module 'StroyBotMonorepoWebPartStrings' {
|
||||
const strings: IStroyBotMonorepoWebPartStrings;
|
||||
export = strings;
|
||||
}
|
||||
Reference in New Issue
Block a user