feat(records): scheduled records empty-state hint
Fixes UX bug: пользователь создаёт запись с validFrom на будущую дату
(e.g. 18.05), открывает catalog 15.05 — пустая таблица, никаких хинтов
о том что запись существует.
Backend (read-api):
- New endpoint GET /api/v1/{dict}/records/scheduled-summary
- Returns {count, nearestValidFrom} для записей с validFrom > now AND
validTo > now в текущем scope view
- Single-aggregate JPA query (COUNT + MIN), respect scope filter same way
as listActive — INTERNAL scheduled не учитывается для PUBLIC consumer'а
Frontend:
- New useScheduledSummary hook + ScheduledRecordsSummary type
- Empty-state в dictionaries.$name now shows: "Запланировано N, ближайшая
на DD.MM [Перейти к этой дате]" с CTA который set'ает time-travel на
nearestValidFrom
- i18n keys для ru/en plural forms
Связан с MR !219 (TimeTravelPicker 30-day future window) — теперь slider
позволяет дойти до даты записи, и empty-state hint позволяет узнать что
запись существует.
This commit is contained in:
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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».
|
||||
*
|
||||
* <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 ScopeAccessDeniedException(String message) { super(message); }
|
||||
}
|
||||
|
||||
+22
@@ -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.
|
||||
*
|
||||
* <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}. */
|
||||
public static class BadBboxException extends RuntimeException {
|
||||
public BadBboxException(String message) { super(message); }
|
||||
|
||||
Reference in New Issue
Block a user