Merge branch 'feat/scheduled-records-hint' into 'main'

feat(records): scheduled records empty-state hint

See merge request 2-6/2-6-4/terravault/ordinis!222
This commit is contained in:
Александр Зимин
2026-05-15 17:20:53 +00:00
8 changed files with 205 additions and 1 deletions
+15
View File
@@ -756,3 +756,18 @@ export const NOTIFICATION_EVENT_TYPES = [
'RecordDraftWithdrawn', 'RecordDraftWithdrawn',
] as const ] as const
export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number] export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number]
/**
* Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с
* {@code validFrom > now AND validTo > now} в текущем scope view.
*
* <ul>
* <li>{@code count=0} → frontend hint не показывает.</li>
* <li>{@code nearestValidFrom} — ISO datetime ближайшей запланированной;
* используется в CTA «Перейти к дате» (time-travel).</li>
* </ul>
*/
export type ScheduledRecordsSummary = {
count: number
nearestValidFrom: string | null
}
+32
View File
@@ -20,6 +20,7 @@ import {
type RecordDependentsPage, type RecordDependentsPage,
type RecordResponse, type RecordResponse,
type ReviewQueuePage, type ReviewQueuePage,
type ScheduledRecordsSummary,
type SchemaDependent, type SchemaDependent,
type SearchResponse, type SearchResponse,
type WebhookDeliveryPage, type WebhookDeliveryPage,
@@ -784,6 +785,37 @@ export const useRecords = (
scopeCsv: string, scopeCsv: string,
filter?: RecordsFilter, filter?: RecordsFilter,
) => useQuery(recordsQuery(dictionaryName, scopeCsv, filter)) ) => useQuery(recordsQuery(dictionaryName, scopeCsv, filter))
/**
* Scheduled records hint для empty-state. Fetch'ит count записей с
* validFrom > now (но validTo > now — т.е. не сразу закрытые). Показывается
* пользователю когда список пуст, но в будущем что-то запланировано.
*
* <p>Fixes UX bug: user создал запись с validFrom=18.05, открыл catalog 15.05 —
* пустая таблица, никаких хинтов. Теперь UI показывает «Запланировано: 1
* запись на 18.05 [перейти к дате]».
*
* <p>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<ScheduledRecordsSummary> => {
const { data } = await apiClient.get<ScheduledRecordsSummary>(
`/${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 = ( export const useRecordRaw = (
dictionaryName: string, dictionaryName: string,
businessKey: string | undefined, businessKey: string | undefined,
+14
View File
@@ -349,6 +349,15 @@ i18n
'dict.list.fk.usedBy_many': '← {{count}} использ.', 'dict.list.fk.usedBy_many': '← {{count}} использ.',
'dict.list.fk.usedBy_other': '← {{count}} использ.', 'dict.list.fk.usedBy_other': '← {{count}} использ.',
'dict.empty': 'В этом справочнике пока нет записей', '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.businessKey': 'Бизнес-ключ',
'dict.col.scope': 'Scope', 'dict.col.scope': 'Scope',
'dict.col.validFrom': 'Действует с', 'dict.col.validFrom': 'Действует с',
@@ -1151,6 +1160,11 @@ i18n
'dict.list.fk.usedBy_one': '← {{count}} used by', 'dict.list.fk.usedBy_one': '← {{count}} used by',
'dict.list.fk.usedBy_other': '← {{count}} used by', 'dict.list.fk.usedBy_other': '← {{count}} used by',
'dict.empty': 'No records in this dictionary yet', '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.businessKey': 'Business key',
'dict.col.scope': 'Scope', 'dict.col.scope': 'Scope',
'dict.col.validFrom': 'Valid from', 'dict.col.validFrom': 'Valid from',
@@ -35,6 +35,7 @@ import {
useDictPendingDrafts, useDictPendingDrafts,
useRecordRaw, useRecordRaw,
useRecords, useRecords,
useScheduledSummary,
type RecordsFilter, type RecordsFilter,
} from '@/api/queries' } from '@/api/queries'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations' import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
@@ -169,6 +170,16 @@ function DictionaryDetail() {
} }
: undefined : undefined
const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter) 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 её не видно».
*
* <p>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 для бейджа * Approval Workflow v2 Phase 4: pending drafts на этот dict для бейджа
* "На review" в каждой строке records table. Polled 30s. Bypass'нем если * "На review" в каждой строке records table. Polled 30s. Bypass'нем если
@@ -974,7 +985,30 @@ function DictionaryDetail() {
)} )}
{recordsResult.data && recordsResult.data.length === 0 && ( {recordsResult.data && recordsResult.data.length === 0 && (
<EmptyState title={t('dict.empty')} /> <EmptyState
title={t('dict.empty')}
description={
scheduledSummary.data && scheduledSummary.data.count > 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
? (
<button
type="button"
onClick={() => setTimeTravelAt(scheduledSummary.data!.nearestValidFrom!)}
className="text-cell text-accent hover:underline"
>
{t('dict.scheduled.cta')}
</button>
)
: undefined
}
/>
)} )}
{recordsResult.data && recordsResult.data.length > 0 && ( {recordsResult.data && recordsResult.data.length > 0 && (
@@ -178,4 +178,29 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
@Param("from") OffsetDateTime from, @Param("from") OffsetDateTime from,
@Param("to") OffsetDateTime to, @Param("to") OffsetDateTime to,
@Param("granularity") String granularity); @Param("granularity") String granularity);
/**
* Scheduled records summary: записи которые ещё не активны (validFrom > now),
* но запланированы на будущее (validTo > now т.е. не сразу закрытые).
* Используется empty-state hint в admin-UI: «У словаря есть N запланированных
* на DD.MM» fixes UX bug когда пользователь создаёт запись с validFrom
* на будущую дату и не видит её в списке.
*
* <p>Single query returns {@code [count Long, nearestValidFrom OffsetDateTime]}.
* Если scheduled нет count=0, nearest=null.
*
* <p>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<Object[]> findScheduledSummary(
@Param("dictionaryId") UUID dictionaryId,
@Param("allowedScopes") Collection<DataScope> allowedScopes,
@Param("now") OffsetDateTime now);
} }
@@ -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).
*
* <p>{@code count == 0} {@code nearestValidFrom == null}; frontend
* не показывает hint в этом случае.
*
* <p>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);
}
}
@@ -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.DictionaryRecord;
import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository; import cloud.nstart.terravault.ordinis.domain.record.DictionaryRecordRepository;
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse; 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.LocaleAwareJsonFlattener;
import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator; import cloud.nstart.terravault.ordinis.readapi.locale.LocaleNegotiator;
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox; import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
@@ -172,6 +173,45 @@ public class RecordReadService {
.toList(); .toList();
} }
/**
* Scheduled records summary: подсчитать записи у которых validFrom в будущем
* (но validTo тоже в будущем т.е. не сразу закрытые). Используется
* admin-UI empty-state hint: «У dict есть N запланированных, ближайшая на DD.MM».
*
* <p>Scope rules:
* <ul>
* <li>Если scope словаря недоступен 404 (NoSuchElementException), как в listActive</li>
* <li>Иначе counts только записи в {@code allowedScopes} (INTERNAL не виден PUBLIC consumer'у)</li>
* </ul>
*
* <p>Empty result (count=0) нормальное состояние, возвращается явный
* {@link ScheduledRecordsSummary#empty()} (не null) для уменьшения NPE-риска
* на frontend.
*/
@Transactional(readOnly = true)
public ScheduledRecordsSummary scheduledSummary(
String dictionaryName, List<DataScope> 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<Object[]> 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 static class ScopeAccessDeniedException extends RuntimeException {
public ScopeAccessDeniedException(String message) { super(message); } public ScopeAccessDeniedException(String message) { super(message); }
} }
@@ -3,6 +3,7 @@ package cloud.nstart.terravault.ordinis.readapi.web;
import cloud.nstart.terravault.ordinis.auth.ScopeContext; import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse; 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.service.RecordReadService;
import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox; import cloud.nstart.terravault.ordinis.readapi.spatial.BoundingBox;
import cloud.nstart.terravault.ordinis.readapi.spatial.GeoJsonPolygon; import cloud.nstart.terravault.ordinis.readapi.spatial.GeoJsonPolygon;
@@ -105,6 +106,27 @@ public class DictionaryReadController {
return new ResponseEntity<>(items, headers, 200); 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.
*
* <p>Use case: пользователь создаёт запись с {@code validFrom} на будущее (e.g. 18.05),
* затем заходит в catalog 15.05 табличка пустая. Frontend читает этот endpoint
* и показывает «У словаря есть N запланированных, ближайшая на DD.MM. [Перейти к дате]».
*
* <p>{@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<DataScope> scopes = scopeContext.resolveForRead(asScope).stream().toList();
return service.scheduledSummary(dictionaryName, scopes);
}
/** Bbox parse errors → 400, не 500. Ловится в {@code ReadApiExceptionHandler}. */ /** Bbox parse errors → 400, не 500. Ловится в {@code ReadApiExceptionHandler}. */
public static class BadBboxException extends RuntimeException { public static class BadBboxException extends RuntimeException {
public BadBboxException(String message) { super(message); } public BadBboxException(String message) { super(message); }