diff --git a/ordinis-admin-ui/package.json b/ordinis-admin-ui/package.json index 11835cb..deafbda 100644 --- a/ordinis-admin-ui/package.json +++ b/ordinis-admin-ui/package.json @@ -31,6 +31,7 @@ "axios": "^1.7.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "d3-force": "^3.0.0", "d3-geo": "^3.1.1", "i18next": "^26.0.3", @@ -39,6 +40,7 @@ "lucide-react": "^1.14.0", "oidc-client-ts": "^3.5.0", "react": "^19.0.0", + "react-day-picker": "^10.0.0", "react-dom": "^19.0.0", "react-force-graph-3d": "^1.29.1", "react-hook-form": "^7.54.2", diff --git a/ordinis-admin-ui/pnpm-lock.yaml b/ordinis-admin-ui/pnpm-lock.yaml index 8fe3b88..2bb8b3a 100644 --- a/ordinis-admin-ui/pnpm-lock.yaml +++ b/ordinis-admin-ui/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) d3-force: specifier: ^3.0.0 version: 3.0.0 @@ -92,6 +95,9 @@ importers: react: specifier: ^19.0.0 version: 19.2.5 + react-day-picker: + specifier: ^10.0.0 + version: 10.0.0(react@19.2.5) react-dom: specifier: ^19.0.0 version: 19.2.5(react@19.2.5) @@ -378,6 +384,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} @@ -1959,6 +1968,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -2121,6 +2136,9 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3246,6 +3264,12 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-day-picker@10.0.0: + resolution: {integrity: sha512-lrEXo5wFPsq5LTcayelM3BPueD00v7zbdipAY+EIdPcseVykYwkOWx4Ujn/EtbBvpnp8ZPUHol17HXH6kVbZoA==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: @@ -4149,6 +4173,8 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@date-fns/tz@1.4.1': {} + '@dimforge/rapier3d-compat@0.12.0': {} '@esbuild/aix-ppc64@0.21.5': @@ -5597,6 +5623,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -5758,6 +5796,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + date-fns@4.1.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -6731,6 +6771,12 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-day-picker@10.0.0(react@19.2.5): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + react: 19.2.5 + react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 diff --git a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx index a38018e..b15cc9b 100644 --- a/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx +++ b/ordinis-admin-ui/src/components/form/SchemaDrivenForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form' import { useTranslation } from 'react-i18next' import Ajv, { type ErrorObject } from 'ajv' @@ -30,6 +30,7 @@ import { parseFormDate, } from '@/lib/dates' import { pickLocalized } from '@/lib/locales' +import { cn } from '@/lib/utils' type FormValues = { businessKey: string @@ -361,21 +362,112 @@ export const SchemaDrivenForm = ({ // strip сверху (sticky), он же выполняет функцию quick-jump. const formContainerRef = useRef(null) + // Scroll-spy: IntersectionObserver следит за section anchors, highlight'ит + // chip активной секции (= видимая ближе всего к верху scroll viewport'а). + // rootMargin '-44px 0px -60% 0px' создаёт «detection band» в верхней 40% + // viewport'а: header sticky ~44px сверху, section считается «активной» + // если её top попадает в этот диапазон. Это даёт ощущение «active = что + // юзер сейчас читает», а не «что в самом верху». threshold 0 — fires при + // любом пересечении границы, дешевле чем массив тысяч threshold'ов. + // + // Сохраняем Map и считаем active как первую секцию + // в sectionOrder у которой isIntersecting=true. Это устойчивее чем + // «entries[0].target.id» — observer callback приходит batched и порядок + // entries не гарантирован. + const [activeSection, setActiveSection] = useState(null) + const intersectingRef = useRef>(new Map()) + + const handleIntersect = useCallback( + (entries: IntersectionObserverEntry[]) => { + const map = intersectingRef.current + for (const entry of entries) { + const id = (entry.target as HTMLElement).dataset.sectionId as SectionId | undefined + if (!id) continue + map.set(id, entry.isIntersecting) + } + // Pick first in sectionOrder that is currently intersecting. Fallback — + // оставляем предыдущий active (избегаем «мигания» в null когда юзер + // между секциями в gap-промежутке). + for (const id of sectionOrder) { + if (map.get(id)) { + setActiveSection(id) + return + } + } + }, + [sectionOrder], + ) + + useEffect(() => { + if (!showAllSections) { + setActiveSection(null) + intersectingRef.current.clear() + return + } + const form = formContainerRef.current + if (!form) return + // root: ближайший scrollable ancestor — drawer body. null = viewport, + // но для embedded drawer'а правильнее scoped root. Ищем через closest + // overflow-y-auto. Если не найдено — fallback на null (window scroll). + let root: Element | null = form.parentElement + while (root && root !== document.body) { + const style = window.getComputedStyle(root) + if ( + style.overflowY === 'auto' || + style.overflowY === 'scroll' || + style.overflowY === 'overlay' + ) { + break + } + root = root.parentElement + } + const observer = new IntersectionObserver(handleIntersect, { + root: root && root !== document.body ? root : null, + // Top 44px reserved для chip strip sticky. Bottom -60% — секция + // считается active пока её top в верхних 40% viewport'а. + rootMargin: '-44px 0px -60% 0px', + threshold: 0, + }) + // Observe все section anchor divs (data-section-id). Querying inside + // form ref scope чтобы не зацепить чужие элементы. + const anchors = form.querySelectorAll('[data-section-id]') + anchors.forEach((el) => observer.observe(el)) + return () => { + observer.disconnect() + intersectingRef.current.clear() + } + // sectionOrder в deps чтобы при изменении schema'ы observer'ы пересоздались + // для новых anchor'ов. + }, [showAllSections, handleIntersect, sectionOrder]) + const sectionsChipStrip = showAllSections ? ( -
- {visibleTabs.map((tab) => ( - - ))} +
+ {visibleTabs.map((tab) => { + const isActive = activeSection === tab.id + return ( + + ) + })}
) : null @@ -411,6 +503,7 @@ export const SchemaDrivenForm = ({
{showAllSections && (isIdentity || fields.length > 0) && ( diff --git a/ordinis-admin-ui/src/components/record/RecordDrawer.tsx b/ordinis-admin-ui/src/components/record/RecordDrawer.tsx index ba58354..56d1c2d 100644 --- a/ordinis-admin-ui/src/components/record/RecordDrawer.tsx +++ b/ordinis-admin-ui/src/components/record/RecordDrawer.tsx @@ -37,11 +37,17 @@ import { cn } from '@/lib/utils' * localStorage key {@code ord-drawer-wide}. * * - *

Phase 1 (current): drawer shell + width toggle + sticky chrome. - * Phase 2 (TODO Stage 3.5b): section anchor chips, wide-mode ToC rail - * (180px), search field, "только заполненные" toggle. Требует {@code x-section} - * metadata в schemaJson — пока секционирование делает SchemaDrivenForm своими - * 3 tabs. + *

Status (v2.12+): + *

    + *
  • ✅ Drawer shell + width toggle + sticky chrome (Phase 1).
  • + *
  • ✅ Toolbar: search полей + «только заполненные» (props.toolbar).
  • + *
  • ✅ Section anchor chips + scroll-spy active highlight — рендерит + * SchemaDrivenForm в sections layout (sticky chip strip с + * IntersectionObserver).
  • + *
  • ❌ Wide-mode left ToC rail — отклонено по user feedback + * («в редакторе записей левая навигация лишняя»); chip strip + * выполняет ту же функцию quick-jump компактнее.
  • + *
* *

Drives form submit через атрибут {@code form={formId}} на footer Save * button. SchemaDrivenForm должен получить тот же {@code formId} prop и @@ -226,9 +232,9 @@ export function RecordDrawer({ )} {/* === BODY (scrolling) === - * Phase B остатки (deferred — требует x-section schema metadata): - * - section anchor chips strip над form sections - * - wide-mode left ToC rail (180px) в CSS grid 180px/1fr layout */} + * SchemaDrivenForm в sections layout рендерит свой sticky chip + * strip + IntersectionObserver scroll-spy. Drawer body — просто + * overflow-y-auto контейнер, scroll root для observer'а. */}

{children}
{/* === FOOTER (sticky bottom) === */} diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 813d03f..6a33b6c 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -161,6 +161,7 @@ i18n 'webhooks.field.dictionaryFilterHint': 'Через запятую: spacecraft, satellite_type. Пусто = все', 'webhooks.field.eventTypeFilter': 'Типы событий', 'webhooks.field.eventTypeFilterHint': 'Record*, Definition*, SchemaDraft*. Пусто = все события.', + 'webhooks.field.eventTypeSearch': 'Поиск типа события', 'webhooks.field.description': 'Описание', 'webhooks.field.createdBy': 'Создатель', 'webhooks.error.nameUrlRequired': 'Имя и URL обязательны', @@ -234,6 +235,7 @@ i18n 'audit.filter.businessKey': 'Бизнес-ключ', 'audit.filter.from': 'С', 'audit.filter.to': 'По', + 'audit.filter.range': 'Период', 'audit.filter.reset': 'Сбросить', 'audit.filter.apply': 'Применить', 'audit.filter.active': 'Активные фильтры', @@ -381,6 +383,9 @@ i18n 'drawer.close': 'Закрыть', 'drawer.captionEdit': 'редактирование записи · {{dict}}', 'drawer.captionCreate': 'новая запись · {{dict}}', + 'drawer.sectionsNav': 'Секции формы', + 'drawer.searchPlaceholder': 'Поиск полей', + 'drawer.onlyFilled': 'только заполненные', 'scope.PUBLIC': 'PUBLIC — публичный', 'scope.INTERNAL': 'INTERNAL — внутренний', 'scope.RESTRICTED': 'RESTRICTED — ограниченный', @@ -843,6 +848,7 @@ i18n 'webhooks.field.dictionaryFilterHint': 'Comma-separated: spacecraft, satellite_type. Empty = all', 'webhooks.field.eventTypeFilter': 'Event types', 'webhooks.field.eventTypeFilterHint': 'Record*, Definition*, SchemaDraft*. Empty = all events.', + 'webhooks.field.eventTypeSearch': 'Search event type', 'webhooks.field.description': 'Description', 'webhooks.field.createdBy': 'Created by', 'webhooks.error.nameUrlRequired': 'Name and URL are required', @@ -914,6 +920,7 @@ i18n 'audit.filter.businessKey': 'Business key', 'audit.filter.from': 'From', 'audit.filter.to': 'To', + 'audit.filter.range': 'Date range', 'audit.filter.reset': 'Reset', 'audit.filter.apply': 'Apply', 'audit.filter.active': 'Active filters', @@ -1052,6 +1059,9 @@ i18n 'drawer.close': 'Close', 'drawer.captionEdit': 'edit record · {{dict}}', 'drawer.captionCreate': 'new record · {{dict}}', + 'drawer.sectionsNav': 'Form sections', + 'drawer.searchPlaceholder': 'Search fields', + 'drawer.onlyFilled': 'only filled', 'form.error.required': 'Required field', 'scope.PUBLIC': 'PUBLIC', 'scope.INTERNAL': 'INTERNAL', diff --git a/ordinis-admin-ui/src/routes/audit.tsx b/ordinis-admin-ui/src/routes/audit.tsx index 87d8dfc..d1512e0 100644 --- a/ordinis-admin-ui/src/routes/audit.tsx +++ b/ordinis-admin-ui/src/routes/audit.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { Badge, Button, - DatePicker, + DateRangePicker, EmptyState, IconButton, LoadingBlock, @@ -380,34 +380,29 @@ function FilterPanel({ onChange={(e) => set('businessKey', e.target.value || undefined)} onKeyDown={handleEnter} /> - - set( - 'from', - d - ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( - d.getDate(), - ).padStart(2, '0')}T00:00:00${localTzOffset(d)}` - : undefined, - ) - } - /> - - set( - 'to', - d - ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String( - d.getDate(), - ).padStart(2, '0')}T23:59:59${localTzOffset(d)}` - : undefined, - ) - } - /> + {/* DateRangePicker заменяет пару отдельных DatePicker'ов — один + * popover с двумя месяцами, выбор start/end в одном UI. ISO sтроки + * c local TZ offset формируются здесь, чтобы backend получал + * правильный bounds (T00:00:00 для from, T23:59:59 для to). */} +
+ { + const fromIso = from + ? `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, '0')}-${String( + from.getDate(), + ).padStart(2, '0')}T00:00:00${localTzOffset(from)}` + : undefined + const toIso = to + ? `${to.getFullYear()}-${String(to.getMonth() + 1).padStart(2, '0')}-${String( + to.getDate(), + ).padStart(2, '0')}T23:59:59${localTzOffset(to)}` + : undefined + onChange({ ...filters, from: fromIso, to: toIso }) + }} + /> +