diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..969ade8 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,105 @@ +name: Build & Deploy to SharePoint Online + +# Скопировано из tnsu-approval-route (.gitea/workflows/deploy_frontend.yml). +# Отличия — только в путях и идентификаторах: там SPFx-проект лежит в подпапке +# frontend/, здесь он и есть корень репозитория, поэтому working-directory и +# префиксы frontend/ убраны. +# +# Запуск только вручную (workflow_dispatch): деплой идёт сразу в каталог +# приложений тенанта, то есть на прод для всех сайтов. +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js 22 + uses: actions/setup-node@v3 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: | + npm config set fetch-retries 5 + npm config set fetch-retry-mintimeout 10000 + npm config set fetch-retry-maxtimeout 120000 + for i in 1 2 3; do + npm install --legacy-peer-deps && break + echo "Attempt $i failed, retrying in 20s..." + sleep 20 + done + + # npm run build = heft test --production (сборка + lint + Jest) и следом + # heft package-solution --production, который и создаёт .sppkg. + - name: Build & package SPFx solution + run: npm run build + + - name: Upload .sppkg artifact + uses: actions/upload-artifact@v3 + with: + name: request-portal-sppkg + path: sharepoint/solution/request-portal.sppkg + retention-days: 7 + + - name: Install CLI for Microsoft 365 + run: | + for i in 1 2 3; do + npm install -g @pnp/cli-microsoft365@9 && break + echo "Attempt $i failed, retrying in 20s..." + sleep 20 + done + + - name: Login to Microsoft 365 (certificate) + run: | + m365 login \ + --authType certificate \ + --appId "${{ secrets.SPO_CLIENT_ID }}" \ + --certificateBase64Encoded "${{ secrets.SPO_CERT_BASE64 }}" \ + --thumbprint "${{ secrets.SPO_CERT_THUMBPRINT }}" \ + --tenant "${{ secrets.SPO_TENANT_ID }}" + + # SOLUTION_ID — solution.id из config/package-solution.json; при его смене + # правится и здесь, иначе deploy уйдёт в чужое приложение. + - name: Upload & Deploy to App Catalog + env: + SOLUTION_ID: "8c31266a-712c-4c72-ae04-bca5a6a071c3" + CATALOG_SITE: "https://tnsukz.sharepoint.com/sites/appcatalog" + SPPKG: "sharepoint/solution/request-portal.sppkg" + FILE_NAME: "request-portal.sppkg" + run: | + # Получаем токен через уже аутентифицированный m365 CLI + TOKEN=$(m365 util accesstoken get --resource https://tnsukz.sharepoint.com --output text) + echo "Token length: ${#TOKEN}" + + echo ">>> Uploading to App Catalog" + HTTP=$(curl -s -o /tmp/upload.json -w "%{http_code}" -X POST \ + "${CATALOG_SITE}/_api/web/GetFolderByServerRelativeUrl('/sites/appcatalog/AppCatalog')/Files/add(overwrite=true,url='${FILE_NAME}')" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "X-RequestDigest: 0x0" \ + -H "Accept: application/json;odata=nometadata" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @"${SPPKG}" || echo "000") + echo "Upload HTTP: ${HTTP}" + cat /tmp/upload.json + [ "$HTTP" = "200" ] || [ "$HTTP" = "201" ] || exit 1 + + echo ">>> Deploying app (id: ${SOLUTION_ID})" + HTTP=$(curl -s -o /tmp/deploy.json -w "%{http_code}" -X POST \ + "${CATALOG_SITE}/_api/web/tenantappcatalog/AvailableApps/GetById('${SOLUTION_ID}')/deploy" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "X-RequestDigest: 0x0" \ + -H "Accept: application/json;odata=nometadata" \ + -H "Content-Type: application/json" \ + -d '{"skipFeatureDeployment":true}' || echo "000") + echo "Deploy HTTP: ${HTTP}" + cat /tmp/deploy.json + [ "$HTTP" = "200" ] || [ "$HTTP" = "204" ] || exit 1 + + echo ">>> Done" diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index c68f148..3ee3ac1 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -12,6 +12,7 @@ import * as strings from 'RequestPortalWebPartStrings'; import RequestPortal from './components/RequestPortal'; import { IRequestPortalProps } from './components/IRequestPortalProps'; import { initSP } from './shared/pnp/pnpjs'; +import { installErrorLogging } from './shared/diagnostics'; import { ErrorOverlay } from './shared/ui'; export interface IRequestPortalWebPartProps { @@ -41,6 +42,7 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart { + installErrorLogging(); // до всего остального — чтобы поймать и ранние отклонения initSP(this.context); // единый клиент PnPjs для записи в списки return this._getEnvironmentMessage().then(message => { this._environmentMessage = message; diff --git a/src/webparts/requestPortal/components/RequestPortal.tsx b/src/webparts/requestPortal/components/RequestPortal.tsx index 0bfc468..f5df610 100644 --- a/src/webparts/requestPortal/components/RequestPortal.tsx +++ b/src/webparts/requestPortal/components/RequestPortal.tsx @@ -2,7 +2,20 @@ import * as React from 'react'; import styles from './RequestPortal.module.scss'; import type { IRequestPortalProps } from './IRequestPortalProps'; import { IconSprite } from '../shared/ui/Icon/IconSprite'; -import { navigate, useRoute, NavSection } from '../shared/routing'; +import { + navigate, + persistOwnTab, + persistOwnStatus, + persistPage, + persistQuery, + storedOwnStatus, + storedOwnTab, + storedPageFor, + storedQueryFor, + useRoute, + NavSection, + OwnTab, +} from '../shared/routing'; import { onRequestsChanged, emitRequestsChanged } from '../shared/requestEvents'; import { confirmDiscardFormChanges, markFormClean, markFormDirty } from '../shared/unsavedChanges'; import { SideNav } from './SideNav/SideNav'; @@ -13,9 +26,10 @@ import { REQUEST_TYPES } from '../data/requests'; import SubmitFeature from '../features/submit/SubmitFeature'; import ApproveFeature from '../features/approve/ApproveFeature'; import EditFeature from '../features/edit/EditFeature'; -import { RequestsTableView } from '../features/myRequests/RequestsTableView'; +import { ListView, RequestsTableView } from '../features/myRequests/RequestsTableView'; +import { OwnTabs } from '../features/myRequests/OwnTabs'; import { SearchView } from '../features/myRequests/SearchView'; -import { getPendingApproval } from '../features/myRequests/myRequests.service'; +import { getNavCounts, INavCounts } from '../features/myRequests/myRequests.service'; import { IMyRequest } from '../features/myRequests/models/IMyRequest'; import { isEditableStatus } from '../shared/approval'; @@ -26,6 +40,16 @@ import { isEditableStatus } from '../shared/approval'; // 'approvals'/'own' — таблицы заявок. Клик по строке таблицы открывает заявку // в выезжающей панели ПОВЕРХ раздела (rpmode=approve&rptype=&rpid=), поэтому // раздел под ней сохраняется; та же ссылка приходит из потока Power Automate. +// Разделы, которые показывают таблицу заявок, и соответствующее ей +// представление. У 'own' оно зависит ещё и от подвкладки (см. ниже), поэтому +// здесь стоит заглушка — важен сам факт наличия ключа. +const LIST_VIEWS: Partial> = { + approvals: 'approvals', + execution: 'execution', + rework: 'rework', + own: 'own:created', +}; + const RequestPortal: React.FC = ({ hasTeamsContext, context }) => { const rootClasses = [styles.requestPortal, hasTeamsContext ? styles.teams : ''] .filter(Boolean) @@ -33,7 +57,7 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context const route = useRoute(); const [navCollapsed, setNavCollapsed] = React.useState(false); - const [approvalsCount, setApprovalsCount] = React.useState(undefined); + const [navCounts, setNavCounts] = React.useState(undefined); const [activeCategoryId, setActiveCategoryId] = React.useState(() => { if (route.mode === 'create' && route.formKey) { @@ -43,22 +67,21 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context return DEFAULT_CATEGORY_ID; }); - // Счётчик на пункте «На согласовании». Обновляется по тому же событию, что и - // сами таблицы (создание заявки, решение согласующего), и на монтировании - // (в т.ч. на обновление страницы). Когда сама вкладка «На согласовании» - // открыта, RequestsTableView сообщает точное число через onMyTurnCountChange - // (ниже) — это перезаписывает значение отсюда, чтобы не было гонки между - // двумя независимыми запросами одних и тех же данных. + // Счётчики на пунктах-очередях («На согласовании», «На исполнении», + // «Доработка»). Обновляются по тому же событию, что и сами таблицы (создание + // заявки, решение согласующего), и на монтировании (в т.ч. на обновление + // страницы). Все три приходят из одного вызова getNavCounts — он же следит, + // чтобы цифры не разошлись с содержимым вкладок. React.useEffect(() => { let cancelled = false; const load = (): void => { - getPendingApproval(context) - .then((r) => { - if (!cancelled) setApprovalsCount(r.totalRows); + getNavCounts(context) + .then((c) => { + if (!cancelled) setNavCounts(c); }) .catch(() => { - if (!cancelled) setApprovalsCount(undefined); + if (!cancelled) setNavCounts(undefined); }); }; @@ -100,7 +123,88 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context // таблица на него подписана и перечитает данные фоном, не убирая с экрана // текущие строки (см. RequestsTableView/SearchView). if (nav === route.nav && route.mode === 'submit') emitRequestsChanged(); - navigate({ mode: 'submit', nav }); + // Всё восстанавливаемое состояние раздела передаём ЯВНО, из запомненного. + // parseRoute подставил бы его и сам, но тогда оно осталось бы только в + // состоянии и не попало бы в адрес: ссылка на раздел вела бы на первую + // страницу без фильтра, а на экране была бы открыта другая. + // + // Брать route.ownTab/route.ownStatus здесь нельзя: при переходе ИЗ другого + // раздела они undefined (parseRoute заполняет их только при nav==='own'), + // и явная передача сбросила бы выбор пользователя. + if (nav === 'own') { + const ownTab = storedOwnTab(); + navigate({ + mode: 'submit', + nav, + ownTab, + ownStatus: storedOwnStatus(ownTab), + page: storedPageFor('own', ownTab), + query: storedQueryFor('own', ownTab) || undefined, + }); + return; + } + navigate({ + mode: 'submit', + nav, + page: storedPageFor(nav), + query: storedQueryFor(nav) || undefined, + }); + }; + + const gotoOwnTab = (ownTab: OwnTab): void => { + // Фильтр и страница — из запомненного для ЭТОЙ подвкладки (см. goto выше о + // том, почему не из route). На вкладках с решениями фильтра нет по смыслу — + // storedOwnStatus сам вернёт undefined. + persistOwnTab(ownTab); + navigate({ + mode: 'submit', + nav: 'own', + ownTab, + ownStatus: storedOwnStatus(ownTab), + page: storedPageFor('own', ownTab), + query: storedQueryFor('own', ownTab) || undefined, + }); + }; + + const setOwnStatus = (ownStatus: string): void => { + const normalized = ownStatus || undefined; + persistOwnStatus(normalized); + // Смена фильтра — другой набор заявок, поэтому явно с первой страницы + // (и запоминаем именно её, иначе вернувшись сюда попали бы на старую). + persistPage({ mode: 'submit', nav: 'own', ownTab: 'created' }, 1); + // Текст поиска переносим ЯВНО: раздел тот же, поле на экране не очищается — + // значит и в адресе запрос должен остаться. Без этого rpq пропадал из + // ссылки при каждой смене фильтра (в самом состоянии он всё равно + // восстанавливался из запомненного, поэтому расхождение адреса с экраном + // было видно не сразу). + navigate({ + mode: 'submit', + nav: 'own', + ownTab: 'created', + ownStatus: normalized, + page: 1, + query: route.query || undefined, + }); + }; + + // Смена страницы сохраняет ВЕСЬ остальной маршрут (в т.ч. открытую заявку), + // меняется только номер: так «назад» возвращает на прежнюю страницу, а + // ссылку со страницей можно передать. + const setPage = (page: number): void => { + persistPage(route, page); + navigate({ ...route, page }); + }; + + // Новый запрос — другой набор результатов, поэтому вместе с ним всегда первая + // страница (иначе можно приземлиться на несуществующую). + // + // replace, а не push: текст уходит сюда после КАЖДОЙ паузы в наборе, и с + // обычным переходом каждое слово становилось бы отдельным шагом «назад» — + // чтобы уйти со страницы, пришлось бы «отлистать» весь набранный запрос. + const setQuery = (query: string): void => { + persistQuery(route, query); + persistPage(route, 1); + navigate({ ...route, query: query || undefined, page: 1 }, { replace: true }); }; // Клик по строке таблицы. СВОЯ заявка в незавершённом статусе (черновик, // возвращённая, отклонённая) открывается на редактирование, всё остальное — @@ -112,9 +216,25 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context nav: route.nav, formKey: request.formKey, itemId: request.id, + ownTab: route.ownTab, + ownStatus: route.ownStatus, + // Страница и запрос — часть того же списка под панелью: без них ссылка на + // открытую заявку вела бы на первую страницу без поиска. + page: route.page, + query: route.query, }); - const closeDrawer = (): void => navigate({ mode: 'submit', nav: route.nav }); + // Закрывая панель, возвращаемся ровно в тот список, из которого её открыли — + // включая подвкладку и фильтр «Моих заявок». + const closeDrawer = (): void => + navigate({ + mode: 'submit', + nav: route.nav, + ownTab: route.ownTab, + ownStatus: route.ownStatus, + page: route.page, + query: route.query, + }); // Закрытие панели РЕДАКТИРОВАНИЯ: там открыта форма, и её содержимое живёт // только в памяти — уходя без сохранения, пользователь теряет правки. @@ -132,7 +252,11 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context setNavCollapsed((v) => !v)} /> @@ -181,16 +305,33 @@ const RequestPortal: React.FC = ({ hasTeamsContext, context {route.nav === 'search' && ( )} - {(route.nav === 'approvals' || route.nav === 'own') && ( + {route.nav === 'own' && ( + + )} + + {LIST_VIEWS[route.nav] !== undefined && ( )} diff --git a/src/webparts/requestPortal/components/SideNav/SideNav.tsx b/src/webparts/requestPortal/components/SideNav/SideNav.tsx index ba6119c..8f207bb 100644 --- a/src/webparts/requestPortal/components/SideNav/SideNav.tsx +++ b/src/webparts/requestPortal/components/SideNav/SideNav.tsx @@ -10,9 +10,13 @@ interface INavItem { icon: IconName; } +// Порядок неслучаен: сверху действие (создать), затем три очереди — то, что +// ждёт пользователя прямо сейчас, — и только потом архив и поиск. const ITEMS: INavItem[] = [ { id: 'new', title: 'Новая заявка', icon: 'plus' }, { id: 'approvals', title: 'На согласовании', icon: 'user-check' }, + { id: 'execution', title: 'На исполнении', icon: 'helmet' }, + { id: 'rework', title: 'Доработка', icon: 'edit' }, { id: 'own', title: 'Мои заявки', icon: 'clipboard' }, { id: 'search', title: 'Поиск', icon: 'search' }, ]; @@ -20,8 +24,8 @@ const ITEMS: INavItem[] = [ interface ISideNavProps { activeId: NavSection; onChange: (id: NavSection) => void; - /** Счётчик у пункта «На согласовании» (сколько ждут решения). */ - approvalsCount?: number; + /** Счётчики очередей: сколько заявок ждёт пользователя в каждом разделе. */ + counts?: Partial>; collapsed: boolean; onToggleCollapsed: () => void; } @@ -31,7 +35,7 @@ interface ISideNavProps { export const SideNav: React.FC = ({ activeId, onChange, - approvalsCount, + counts, collapsed, onToggleCollapsed, }) => { @@ -58,7 +62,8 @@ export const SideNav: React.FC = ({ {ITEMS.map((item) => { const isActive = item.id === activeId; const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' '); - const showCount = item.id === 'approvals' && !!approvalsCount; + const count = counts?.[item.id]; + const showCount = !!count; return ( ); })} diff --git a/src/webparts/requestPortal/features/myRequests/OwnTabs.module.scss b/src/webparts/requestPortal/features/myRequests/OwnTabs.module.scss new file mode 100644 index 0000000..cfb5dac --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/OwnTabs.module.scss @@ -0,0 +1,40 @@ +@import '../../../../styles/design-tokens'; + +// Подвкладки «Моих заявок». Оформление намеренно повторяет CategoryTabs +// (вкладки категорий на подаче заявки) — это тот же по смыслу элемент +// «переключатель раздела», и разнобой в двух местах портала выглядел бы ошибкой. +.tabs { + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: $sp-2; + border-bottom: 1px solid $gray-200; + margin-bottom: $sp-4; +} + +.tab { + display: inline-flex; + align-items: center; + padding: 12px 16px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: $gray-500; + font-family: $font-sans; + font-size: 13px; + font-weight: 500; + letter-spacing: .2px; + white-space: nowrap; + cursor: pointer; + transition: color .18s, border-color .18s; + + &:hover { + color: $navy-800; + } +} + +.active { + color: $navy-900; + font-weight: 600; + border-bottom-color: $gold-500; +} diff --git a/src/webparts/requestPortal/features/myRequests/OwnTabs.tsx b/src/webparts/requestPortal/features/myRequests/OwnTabs.tsx new file mode 100644 index 0000000..c08a781 --- /dev/null +++ b/src/webparts/requestPortal/features/myRequests/OwnTabs.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import styles from './OwnTabs.module.scss'; +import { OwnTab } from '../../shared/routing'; + +interface IOwnTabItem { + id: OwnTab; + title: string; +} + +// Суффикс «мной» намеренно опущен: раздел уже называется «Мои заявки», и внутри +// него он повторялся бы в каждой вкладке. Первая вкладка — про авторство, +// остальные три — про решения пользователя в ЧУЖИХ маршрутах. +const ITEMS: IOwnTabItem[] = [ + { id: 'created', title: 'Инициированные' }, + { id: 'approved', title: 'Согласованные' }, + { id: 'rejected', title: 'Отклонённые' }, + { id: 'executed', title: 'Выполненные' }, +]; + +interface IOwnTabsProps { + activeId: OwnTab; + onChange: (id: OwnTab) => void; +} + +/** Горизонтальный переключатель подвкладок раздела «Мои заявки». */ +export const OwnTabs: React.FC = ({ activeId, onChange }) => ( + +); diff --git a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss index effc7d5..411ad0e 100644 --- a/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss +++ b/src/webparts/requestPortal/features/myRequests/RequestsTableView.module.scss @@ -34,6 +34,49 @@ font-weight: 600; } +// Фильтр по статусу — только на вкладке «Инициированные». Стоит слева от поиска +// и прижат к нему: вместе это один блок «чем сузить список». +.statusFilter { + // appearance: none + своя SVG-галочка — как в shared/ui/Select. Нативная + // стрелка ОС рисуется своим шрифтом и рядом с остальными полями портала + // выглядит чужеродно (и по-разному в разных браузерах). + appearance: none; + margin-left: auto; + // Справа место под галочку; по вертикали — как у .search, чтобы оба контрола + // в шапке были одной высоты. + padding: 9px 34px 9px 13px; + border: 1px solid $gray-300; + border-radius: $r-sm; + background-color: $white; + background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + font-family: $font-sans; + font-size: $fs-sm; + color: $gray-900; + cursor: pointer; + transition: .18s; + + @include input-focus-gold; +} + +// Когда фильтр есть, отступ до края уже задан им (margin-left: auto) — иначе +// поиск сам отжимается вправо. +.statusFilter + .search { + margin-left: 0; +} + +// Цвет пунктов списка — те же цвета, что у StatusBadge (см. statusOptionClassName +// в RequestsTableView.tsx). Только color: background-color нативный