diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 5ee7122..5bdd512 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -756,3 +756,18 @@ export const NOTIFICATION_EVENT_TYPES = [ 'RecordDraftWithdrawn', ] as const export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number] + +/** + * Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с + * {@code validFrom > now AND validTo > now} в текущем scope view. + * + * + */ +export type ScheduledRecordsSummary = { + count: number + nearestValidFrom: string | null +} diff --git a/ordinis-admin-ui/src/api/queries.ts b/ordinis-admin-ui/src/api/queries.ts index 29ecb77..046c188 100644 --- a/ordinis-admin-ui/src/api/queries.ts +++ b/ordinis-admin-ui/src/api/queries.ts @@ -20,6 +20,7 @@ import { type RecordDependentsPage, type RecordResponse, type ReviewQueuePage, + type ScheduledRecordsSummary, type SchemaDependent, type SearchResponse, type WebhookDeliveryPage, @@ -784,6 +785,37 @@ export const useRecords = ( scopeCsv: string, filter?: RecordsFilter, ) => useQuery(recordsQuery(dictionaryName, scopeCsv, filter)) + +/** + * Scheduled records hint для empty-state. Fetch'ит count записей с + * validFrom > now (но validTo > now — т.е. не сразу закрытые). Показывается + * пользователю когда список пуст, но в будущем что-то запланировано. + * + *

Fixes UX bug: user создал запись с validFrom=18.05, открыл catalog 15.05 — + * пустая таблица, никаких хинтов. Теперь UI показывает «Запланировано: 1 + * запись на 18.05 [перейти к дате]». + * + *

Stale 30s — scheduled state changes медленно. Refetch on focus = true + * (default) — пользователь возвращается во вкладку → fresh count. + */ +export const scheduledSummaryQuery = (dictionaryName: string, scopeCsv: string) => + queryOptions({ + queryKey: ['scheduled-summary', dictionaryName, scopeCsv] as const, + queryFn: async (): Promise => { + const { data } = await apiClient.get( + `/${dictionaryName}/records/scheduled-summary`, + { params: { as_scope: scopeCsv } }, + ) + return data + }, + staleTime: 30_000, + }) + +export const useScheduledSummary = (dictionaryName: string, scopeCsv: string) => + useQuery({ + ...scheduledSummaryQuery(dictionaryName, scopeCsv), + enabled: Boolean(dictionaryName), + }) export const useRecordRaw = ( dictionaryName: string, businessKey: string | undefined, diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 563dbce..fda171c 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -349,6 +349,15 @@ i18n 'dict.list.fk.usedBy_many': '← {{count}} использ.', 'dict.list.fk.usedBy_other': '← {{count}} использ.', 'dict.empty': 'В этом справочнике пока нет записей', + 'dict.scheduled.hint_one': + 'Запланирована {{count}} запись на {{date}}', + 'dict.scheduled.hint_few': + 'Запланировано {{count}} записи, ближайшая на {{date}}', + 'dict.scheduled.hint_many': + 'Запланировано {{count}} записей, ближайшая на {{date}}', + 'dict.scheduled.hint_other': + 'Запланировано {{count}} записей, ближайшая на {{date}}', + 'dict.scheduled.cta': 'Перейти к этой дате', 'dict.col.businessKey': 'Бизнес-ключ', 'dict.col.scope': 'Scope', 'dict.col.validFrom': 'Действует с', @@ -1151,6 +1160,11 @@ i18n 'dict.list.fk.usedBy_one': '← {{count}} used by', 'dict.list.fk.usedBy_other': '← {{count}} used by', 'dict.empty': 'No records in this dictionary yet', + 'dict.scheduled.hint_one': + '{{count}} record scheduled for {{date}}', + 'dict.scheduled.hint_other': + '{{count}} records scheduled, earliest on {{date}}', + 'dict.scheduled.cta': 'Jump to that date', 'dict.col.businessKey': 'Business key', 'dict.col.scope': 'Scope', 'dict.col.validFrom': 'Valid from', diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index fce8674..8ba2909 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -35,6 +35,7 @@ import { useDictPendingDrafts, useRecordRaw, useRecords, + useScheduledSummary, type RecordsFilter, } from '@/api/queries' import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations' @@ -169,6 +170,16 @@ function DictionaryDetail() { } : undefined const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter) + /** + * Scheduled records hint (post-Phase-B-2 UX fix). Когда records list пустой + * на момент now / time-travel, но в БД есть записи с validFrom > now, + * показываем «Запланировано N, ближайшая на DD.MM [Перейти к этой дате]». + * Fixes user feedback: «создал запись на 18.05 — её не видно». + * + *

Fetch unconditional (cheap single-aggregate query). Frontend сам решает + * показывать hint или нет по {@code count > 0}. + */ + const scheduledSummary = useScheduledSummary(name, 'PUBLIC,INTERNAL,RESTRICTED') /** * Approval Workflow v2 Phase 4: pending drafts на этот dict — для бейджа * "На review" в каждой строке records table. Polled 30s. Bypass'нем если @@ -974,7 +985,30 @@ function DictionaryDetail() { )} {recordsResult.data && recordsResult.data.length === 0 && ( - + 0 && scheduledSummary.data.nearestValidFrom + ? t('dict.scheduled.hint', { + count: scheduledSummary.data.count, + date: new Date(scheduledSummary.data.nearestValidFrom).toLocaleDateString(), + }) + : undefined + } + action={ + scheduledSummary.data && scheduledSummary.data.count > 0 && scheduledSummary.data.nearestValidFrom + ? ( + + ) + : undefined + } + /> )} {recordsResult.data && recordsResult.data.length > 0 && ( diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java index bb09e61..2f7a3c2 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/record/DictionaryRecordRepository.java @@ -178,4 +178,29 @@ public interface DictionaryRecordRepository extends JpaRepository now), + * но запланированы на будущее (validTo > now — т.е. не сразу закрытые). + * Используется empty-state hint в admin-UI: «У словаря есть N запланированных + * на DD.MM» — fixes UX bug когда пользователь создаёт запись с validFrom + * на будущую дату и не видит её в списке. + * + *

Single query → returns {@code [count Long, nearestValidFrom OffsetDateTime]}. + * Если scheduled нет — count=0, nearest=null. + * + *

Filter по scope тот же что в read-side listActive: respects user's + * view permissions. Anonymous (только PUBLIC) → INTERNAL scheduled не учитывается. + */ + @Query(""" + SELECT COUNT(r), MIN(r.validFrom) FROM DictionaryRecord r + WHERE r.dictionaryId = :dictionaryId + AND r.dataScope IN :allowedScopes + AND r.validFrom > :now + AND r.validTo > :now + """) + List findScheduledSummary( + @Param("dictionaryId") UUID dictionaryId, + @Param("allowedScopes") Collection allowedScopes, + @Param("now") OffsetDateTime now); } diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/dto/ScheduledRecordsSummary.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/dto/ScheduledRecordsSummary.java new file mode 100644 index 0000000..2a3ad44 --- /dev/null +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/dto/ScheduledRecordsSummary.java @@ -0,0 +1,22 @@ +package cloud.nstart.terravault.ordinis.readapi.dto; + +import java.time.OffsetDateTime; + +/** + * Empty-state hint payload: сколько записей запланировано в будущем и + * на какую ближайшую дату. Используется admin-UI чтобы показать + * «У словаря есть N запланированных, ближайшая на DD.MM» вместе с + * CTA «Перейти к дате» (set time-travel). + * + *

{@code count == 0} → {@code nearestValidFrom == null}; frontend + * не показывает hint в этом случае. + * + *

Scope filtering: same view permissions that {@code listActive} + * использует — INTERNAL scheduled не учитывается для PUBLIC consumer'а. + */ +public record ScheduledRecordsSummary(long count, OffsetDateTime nearestValidFrom) { + + public static ScheduledRecordsSummary empty() { + return new ScheduledRecordsSummary(0L, null); + } +} diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java index 94dd0e6..821fb0a 100644 --- a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/service/RecordReadService.java @@ -6,6 +6,7 @@ import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinitionRep import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord; import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse; +import cloud.nstart.terravault.ordinis.readapi.dto.ScheduledRecordsSummary; import cloud.nstart.terravault.ordinis.readapi.locale.LocaleAwareJsonFlattener; import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator; import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox; @@ -172,6 +173,45 @@ public class RecordReadService { .toList(); } + /** + * Scheduled records summary: подсчитать записи у которых validFrom в будущем + * (но validTo тоже в будущем — т.е. не сразу закрытые). Используется + * admin-UI empty-state hint: «У dict есть N запланированных, ближайшая на DD.MM». + * + *

Scope rules: + *

+ * + *

Empty result (count=0) — нормальное состояние, возвращается явный + * {@link ScheduledRecordsSummary#empty()} (не null) для уменьшения NPE-риска + * на frontend. + */ + @Transactional(readOnly = true) + public ScheduledRecordsSummary scheduledSummary( + String dictionaryName, List allowedScopes) { + + DictionaryDefinition def = definitionRepository.findByName(dictionaryName) + .orElseThrow(() -> new NoSuchElementException("Dictionary not found: " + dictionaryName)); + + if (!allowedScopes.contains(def.getScope())) { + throw new ScopeAccessDeniedException( + "Scope " + def.getScope() + " not in allowed scopes " + allowedScopes); + } + + OffsetDateTime now = OffsetDateTime.now(); + List rows = recordRepository.findScheduledSummary( + def.getId(), allowedScopes, now); + if (rows.isEmpty()) return ScheduledRecordsSummary.empty(); + + Object[] row = rows.get(0); + long count = row[0] == null ? 0L : ((Number) row[0]).longValue(); + OffsetDateTime nearest = (OffsetDateTime) row[1]; + if (count == 0L || nearest == null) return ScheduledRecordsSummary.empty(); + return new ScheduledRecordsSummary(count, nearest); + } + public static class ScopeAccessDeniedException extends RuntimeException { public ScopeAccessDeniedException(String message) { super(message); } } diff --git a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java index 99a8815..9d47e0d 100644 --- a/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java +++ b/ordinis-read-api/src/main/java/cloud/nstart/terravault/ordinis/readapi/web/DictionaryReadController.java @@ -3,6 +3,7 @@ package cloud.nstart.terravault.ordinis.readapi.web; import cloud.nstart.terravault.ordinis.auth.ScopeContext; import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse; +import cloud.nstart.terravault.ordinis.readapi.dto.ScheduledRecordsSummary; import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService; import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox; import cloud.nstart.terravault.ordinis.readapi.spatial.GeoJsonPolygon; @@ -105,6 +106,27 @@ public class DictionaryReadController { return new ResponseEntity<>(items, headers, 200); } + /** + * Scheduled records summary: empty-state hint для admin-UI. Возвращает + * {@code {count, nearestValidFrom}} для записей с {@code validFrom > now AND validTo > now} + * в текущем scope view. + * + *

Use case: пользователь создаёт запись с {@code validFrom} на будущее (e.g. 18.05), + * затем заходит в catalog 15.05 — табличка пустая. Frontend читает этот endpoint + * и показывает «У словаря есть N запланированных, ближайшая на DD.MM. [Перейти к дате]». + * + *

{@code count=0} → frontend ничего не показывает. Дешёвый single query, OK к poll'ить + * с те же refetchInterval'ом что и records list. + */ + @GetMapping("/scheduled-summary") + public ScheduledRecordsSummary scheduledSummary( + @PathVariable String dictionaryName, + @RequestParam(value = "as_scope", required = false) String asScope) { + + List scopes = scopeContext.resolveForRead(asScope).stream().toList(); + return service.scheduledSummary(dictionaryName, scopes); + } + /** Bbox parse errors → 400, не 500. Ловится в {@code ReadApiExceptionHandler}. */ public static class BadBboxException extends RuntimeException { public BadBboxException(String message) { super(message); }