diff --git a/config/config.json b/config/config.json index ea22522..6f30b2f 100644 --- a/config/config.json +++ b/config/config.json @@ -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": {} } diff --git a/docs/MIGRATION-CHECKLIST.md b/docs/MIGRATION-CHECKLIST.md new file mode 100644 index 0000000..ddac953 --- /dev/null +++ b/docs/MIGRATION-CHECKLIST.md @@ -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`. Проверить на сайте. diff --git a/package-lock.json b/package-lock.json index d4daea8..2f6ee55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 592a5dd..c033360 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/styles/_design-tokens.scss b/src/styles/_design-tokens.scss new file mode 100644 index 0000000..20dde5c --- /dev/null +++ b/src/styles/_design-tokens.scss @@ -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; + } +} diff --git a/src/webparts/stroyBot/StroyBotWebPart.manifest.json b/src/webparts/stroyBot/StroyBotWebPart.manifest.json new file mode 100644 index 0000000..3517a4e --- /dev/null +++ b/src/webparts/stroyBot/StroyBotWebPart.manifest.json @@ -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": {} + } + ] +} diff --git a/src/webparts/stroyBot/StroyBotWebPart.ts b/src/webparts/stroyBot/StroyBotWebPart.ts new file mode 100644 index 0000000..cb09cf6 --- /dev/null +++ b/src/webparts/stroyBot/StroyBotWebPart.ts @@ -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 { + protected async onInit(): Promise { + // 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 }) + ] + } + ] + } + ] + }; + } +} diff --git a/src/webparts/stroyBot/app/App.tsx b/src/webparts/stroyBot/app/App.tsx new file mode 100644 index 0000000..4364ecc --- /dev/null +++ b/src/webparts/stroyBot/app/App.tsx @@ -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 = ({ services }) => ( + + + + + + + +); diff --git a/src/webparts/stroyBot/app/AppServicesContext.ts b/src/webparts/stroyBot/app/AppServicesContext.ts new file mode 100644 index 0000000..de62d69 --- /dev/null +++ b/src/webparts/stroyBot/app/AppServicesContext.ts @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { IAppServices } from '../services/AppServices'; + +// Клиенты к SharePoint / Dataverse / API нужны почти каждой странице — +// поэтому они в контексте, а не передаются пропсами через всё дерево. +export const AppServicesContext = React.createContext(undefined); + +export function useAppServices(): IAppServices { + const services = React.useContext(AppServicesContext); + if (!services) { + throw new Error('useAppServices: компонент отрисован вне AppServicesContext.Provider'); + } + return services; +} diff --git a/src/webparts/stroyBot/app/AppShell.module.scss b/src/webparts/stroyBot/app/AppShell.module.scss new file mode 100644 index 0000000..ba9076e --- /dev/null +++ b/src/webparts/stroyBot/app/AppShell.module.scss @@ -0,0 +1,7 @@ +@import '../../../styles/design-tokens'; + +.content { + width: 100%; + font-family: $font-sans; + color: $gray-900; +} diff --git a/src/webparts/stroyBot/app/AppShell.tsx b/src/webparts/stroyBot/app/AppShell.tsx new file mode 100644 index 0000000..745e9c8 --- /dev/null +++ b/src/webparts/stroyBot/app/AppShell.tsx @@ -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(false); + const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); + + const openSection = (id: string): void => { + setIsMobileMenuOpen(false); + navigate({ page: id as PageId, projectId: route.projectId }); + }; + + return ( + setIsCollapsed((prev) => !prev)} + mobileOpen={isMobileMenuOpen} + onMobileClose={() => setIsMobileMenuOpen(false)} + /> + } + mobileToggle={ setIsMobileMenuOpen(true)} />} + > +
+ +
+
+ ); +}; diff --git a/src/webparts/stroyBot/app/PageOutlet.tsx b/src/webparts/stroyBot/app/PageOutlet.tsx new file mode 100644 index 0000000..49d6e49 --- /dev/null +++ b/src/webparts/stroyBot/app/PageOutlet.tsx @@ -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 ; + } +}; diff --git a/src/webparts/stroyBot/app/SectionPlaceholder.tsx b/src/webparts/stroyBot/app/SectionPlaceholder.tsx new file mode 100644 index 0000000..d2cbe7e --- /dev/null +++ b/src/webparts/stroyBot/app/SectionPlaceholder.tsx @@ -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 = () => ( + +); diff --git a/src/webparts/stroyBot/app/navigation.test.ts b/src/webparts/stroyBot/app/navigation.test.ts new file mode 100644 index 0000000..14f4177 --- /dev/null +++ b/src/webparts/stroyBot/app/navigation.test.ts @@ -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'); + }); +}); diff --git a/src/webparts/stroyBot/app/navigation.ts b/src/webparts/stroyBot/app/navigation.ts new file mode 100644 index 0000000..24dcca5 --- /dev/null +++ b/src/webparts/stroyBot/app/navigation.ts @@ -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 }) + ); +} diff --git a/src/webparts/stroyBot/app/routing/RouteLink.tsx b/src/webparts/stroyBot/app/routing/RouteLink.tsx new file mode 100644 index 0000000..ccfedf3 --- /dev/null +++ b/src/webparts/stroyBot/app/routing/RouteLink.tsx @@ -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; +} + +/** + * Ссылка на раздел приложения. Это настоящий , поэтому работают Ctrl+клик, + * средняя кнопка и «открыть в новой вкладке» (как было в stroy-bot-main), + * а обычный клик переключает раздел без перезагрузки страницы. + */ +export const RouteLink: React.FC = ({ to, className, disabled, children }) => { + const { navigate, buildHref } = useRouter(); + + if (disabled) { + return ( + + {children} + + ); + } + + const onClick = (event: React.MouseEvent): void => { + const opensNewTab = event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0; + if (opensNewTab) { + return; + } + event.preventDefault(); + navigate(to); + }; + + return ( + + {children} + + ); +}; diff --git a/src/webparts/stroyBot/app/routing/RouterContext.tsx b/src/webparts/stroyBot/app/routing/RouterContext.tsx new file mode 100644 index 0000000..8c2e395 --- /dev/null +++ b/src/webparts/stroyBot/app/routing/RouterContext.tsx @@ -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(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(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(() => ({ route, navigate, buildHref }), [route, navigate]); + + return {children}; +}; + +export function useRouter(): IRouterValue { + const router = React.useContext(RouterContext); + if (!router) { + throw new Error('useRouter: компонент отрисован вне RouterProvider'); + } + return router; +} diff --git a/src/webparts/stroyBot/app/routing/routes.test.ts b/src/webparts/stroyBot/app/routing/routes.test.ts new file mode 100644 index 0000000..f278183 --- /dev/null +++ b/src/webparts/stroyBot/app/routing/routes.test.ts @@ -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' }); + }); +}); diff --git a/src/webparts/stroyBot/app/routing/routes.ts b/src/webparts/stroyBot/app/routing/routes.ts new file mode 100644 index 0000000..c38694a --- /dev/null +++ b/src/webparts/stroyBot/app/routing/routes.ts @@ -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> = ['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}` : ''; +} diff --git a/src/webparts/stroyBot/app/texts.ts b/src/webparts/stroyBot/app/texts.ts new file mode 100644 index 0000000..731a598 --- /dev/null +++ b/src/webparts/stroyBot/app/texts.ts @@ -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; diff --git a/src/webparts/stroyBot/config/constants.ts b/src/webparts/stroyBot/config/constants.ts new file mode 100644 index 0000000..c770266 --- /dev/null +++ b/src/webparts/stroyBot/config/constants.ts @@ -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; diff --git a/src/webparts/stroyBot/config/dataverse.ts b/src/webparts/stroyBot/config/dataverse.ts new file mode 100644 index 0000000..c522eea --- /dev/null +++ b/src/webparts/stroyBot/config/dataverse.ts @@ -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'; diff --git a/src/webparts/stroyBot/config/lists.ts b/src/webparts/stroyBot/config/lists.ts new file mode 100644 index 0000000..02707e4 --- /dev/null +++ b/src/webparts/stroyBot/config/lists.ts @@ -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]; diff --git a/src/webparts/stroyBot/services/AppServices.ts b/src/webparts/stroyBot/services/AppServices.ts new file mode 100644 index 0000000..b67176c --- /dev/null +++ b/src/webparts/stroyBot/services/AppServices.ts @@ -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) + }; +} diff --git a/src/webparts/stroyBot/services/http/IHttpClient.ts b/src/webparts/stroyBot/services/http/IHttpClient.ts new file mode 100644 index 0000000..bbf745e --- /dev/null +++ b/src/webparts/stroyBot/services/http/IHttpClient.ts @@ -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; + body?: string | ArrayBuffer; +} + +export interface IHttpResponse { + ok: boolean; + status: number; + statusText: string; + json(): Promise; + text(): Promise; + blob(): Promise; +} + +export interface IHttpClient { + fetch(url: string, request?: IHttpRequest): Promise; +} + +/** Ошибка 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 { + if (response.ok) { + return response; + } + let details = ''; + try { + details = await response.text(); + } catch { + // Тело ошибки может быть недоступно — статуса достаточно. + } + throw new HttpError(response.status, response.statusText, details); +} diff --git a/src/webparts/stroyBot/services/http/spfxClients.ts b/src/webparts/stroyBot/services/http/spfxClients.ts new file mode 100644 index 0000000..18e3355 --- /dev/null +++ b/src/webparts/stroyBot/services/http/spfxClients.ts @@ -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 => + 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 | undefined; + + const getClient = (): Promise => { + if (!clientPromise) { + clientPromise = context.aadHttpClientFactory.getClient(resource); + } + return clientPromise; + }; + + return { + fetch: async (url: string, request: IHttpRequest = {}): Promise => { + const client = await getClient(); + return client.fetch(url, AadHttpClient.configurations.v1, { + method: request.method ?? 'GET', + headers: request.headers, + body: request.body + }); + } + }; +} diff --git a/src/webparts/stroyBot/shared/components/EmptyState.module.scss b/src/webparts/stroyBot/shared/components/EmptyState.module.scss new file mode 100644 index 0000000..1421aed --- /dev/null +++ b/src/webparts/stroyBot/shared/components/EmptyState.module.scss @@ -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; +} diff --git a/src/webparts/stroyBot/shared/components/EmptyState.tsx b/src/webparts/stroyBot/shared/components/EmptyState.tsx new file mode 100644 index 0000000..f4a0a73 --- /dev/null +++ b/src/webparts/stroyBot/shared/components/EmptyState.tsx @@ -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 = ({ icon = 'info', title, text, action }) => ( +
+ + + +

{title}

+ {text &&

{text}

} + {action &&
{action}
} +
+); diff --git a/src/webparts/stroyBotMonorepo/StroyBotMonorepoWebPart.manifest.json b/src/webparts/stroyBotMonorepo/StroyBotMonorepoWebPart.manifest.json deleted file mode 100644 index 7e11be5..0000000 --- a/src/webparts/stroyBotMonorepo/StroyBotMonorepoWebPart.manifest.json +++ /dev/null @@ -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" - } - }] -} diff --git a/src/webparts/stroyBotMonorepo/StroyBotMonorepoWebPart.ts b/src/webparts/stroyBotMonorepo/StroyBotMonorepoWebPart.ts deleted file mode 100644 index 6bae3e8..0000000 --- a/src/webparts/stroyBotMonorepo/StroyBotMonorepoWebPart.ts +++ /dev/null @@ -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 { - - private _isDarkTheme: boolean = false; - private _environmentMessage: string = ''; - - public render(): void { - const element: React.ReactElement = 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 { - return this._getEnvironmentMessage().then(message => { - this._environmentMessage = message; - }); - } - - - - private _getEnvironmentMessage(): Promise { - 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 - }) - ] - } - ] - } - ] - }; - } -} diff --git a/src/webparts/stroyBotMonorepo/assets/welcome-dark.png b/src/webparts/stroyBotMonorepo/assets/welcome-dark.png deleted file mode 100644 index 42f0b8d..0000000 Binary files a/src/webparts/stroyBotMonorepo/assets/welcome-dark.png and /dev/null differ diff --git a/src/webparts/stroyBotMonorepo/assets/welcome-light.png b/src/webparts/stroyBotMonorepo/assets/welcome-light.png deleted file mode 100644 index 69eb3b4..0000000 Binary files a/src/webparts/stroyBotMonorepo/assets/welcome-light.png and /dev/null differ diff --git a/src/webparts/stroyBotMonorepo/components/IStroyBotMonorepoProps.ts b/src/webparts/stroyBotMonorepo/components/IStroyBotMonorepoProps.ts deleted file mode 100644 index 4031a75..0000000 --- a/src/webparts/stroyBotMonorepo/components/IStroyBotMonorepoProps.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface IStroyBotMonorepoProps { - description: string; - isDarkTheme: boolean; - environmentMessage: string; - hasTeamsContext: boolean; - userDisplayName: string; -} diff --git a/src/webparts/stroyBotMonorepo/components/StroyBotMonorepo.module.scss b/src/webparts/stroyBotMonorepo/components/StroyBotMonorepo.module.scss deleted file mode 100644 index 6ea803a..0000000 --- a/src/webparts/stroyBotMonorepo/components/StroyBotMonorepo.module.scss +++ /dev/null @@ -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 - } - } -} \ No newline at end of file diff --git a/src/webparts/stroyBotMonorepo/components/StroyBotMonorepo.tsx b/src/webparts/stroyBotMonorepo/components/StroyBotMonorepo.tsx deleted file mode 100644 index dce7147..0000000 --- a/src/webparts/stroyBotMonorepo/components/StroyBotMonorepo.tsx +++ /dev/null @@ -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 { - public render(): React.ReactElement { - const { - description, - isDarkTheme, - environmentMessage, - hasTeamsContext, - userDisplayName - } = this.props; - - return ( -
-
- -

Well done, {escape(userDisplayName)}!

-
{environmentMessage}
-
Web part property value: {escape(description)}
-
-
-

Welcome to SharePoint Framework!

-

- 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. -

-

Learn more about SPFx development:

- -
-
- ); - } -} diff --git a/src/webparts/stroyBotMonorepo/loc/en-us.js b/src/webparts/stroyBotMonorepo/loc/en-us.js deleted file mode 100644 index 3b25e74..0000000 --- a/src/webparts/stroyBotMonorepo/loc/en-us.js +++ /dev/null @@ -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" - } -}); \ No newline at end of file diff --git a/src/webparts/stroyBotMonorepo/loc/mystrings.d.ts b/src/webparts/stroyBotMonorepo/loc/mystrings.d.ts deleted file mode 100644 index 449537e..0000000 --- a/src/webparts/stroyBotMonorepo/loc/mystrings.d.ts +++ /dev/null @@ -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; -}