From e5a07fc3919104cc73ab4519105360710d1f9992 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Sat, 16 May 2026 12:49:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(records):=20=C2=AB=D0=97=D0=B0=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=C2=BB=20?= =?UTF-8?q?badge=20per=20row=20=D0=B4=D0=BB=D1=8F=20scheduled=20future=20v?= =?UTF-8?q?ersions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last item из QA report backlog'а. Banner + empty-state hint показывают aggregate count, но не помогают find конкретные записи которые скоро изменятся. Badge per row даёт O(1) visual scan. Backend (read-api): - ScheduledRecordsSummary.scheduledBusinessKeys: List (capped 500) - Repo method findScheduledBusinessKeys (DISTINCT subquery) - Single fetch вместо N+1 per-row queries Frontend: - scheduledByKey Set построен из summary - Badge variant=info «Запланировано» рядом с businessKey badge'ом - Title attribute с full RU/EN tooltip - Не блокирует layout — gap-1.5 с pendingReview badge'ем (могут быть оба) i18n keys для ru/en — short label «Запланировано» / «Scheduled». --- ordinis-admin-ui/src/api/client.ts | 6 +++++ ordinis-admin-ui/src/i18n.ts | 4 ++++ .../src/routes/dictionaries.$name.tsx | 23 ++++++++++++++++++ .../record/DictionaryRecordRepository.java | 24 +++++++++++++++++++ .../readapi/dto/ScheduledRecordsSummary.java | 10 ++++++-- .../readapi/service/RecordReadService.java | 10 +++++++- 6 files changed, 74 insertions(+), 3 deletions(-) diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index c3f2234..f8b1820 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -791,4 +791,10 @@ export type ScheduledRecordsSummary = { nearestValidFrom: string | null /** Top-50 sorted asc upcoming validFrom ISO timestamps — для TimeTravel marks. */ upcomingValidFroms: string[] + /** + * Distinct businessKeys имеющие future scheduled version. Frontend строит + * Set для O(1) lookup и рендерит «Scheduled» badge per row. + * Capped 500 (если больше — banner с count'ом всё равно info'нит). + */ + scheduledBusinessKeys: string[] } diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 54d07e6..4649234 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -368,6 +368,8 @@ i18n 'dict.scheduled.hint_other': 'Запланировано {{count}} записей, ближайшая на {{date}}', 'dict.scheduled.cta': 'Перейти к этой дате', + 'dict.scheduled.row.badge': 'Запланировано', + 'dict.scheduled.row.tooltip': 'У записи есть запланированная будущая версия', 'dict.col.businessKey': 'Бизнес-ключ', 'dict.col.scope': 'Scope', 'dict.col.validFrom': 'Действует с', @@ -1185,6 +1187,8 @@ i18n 'dict.scheduled.hint_other': '{{count}} records scheduled, earliest on {{date}}', 'dict.scheduled.cta': 'Jump to that date', + 'dict.scheduled.row.badge': 'Scheduled', + 'dict.scheduled.row.tooltip': 'This record has an upcoming scheduled version', '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 6e3fe8e..830d391 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -195,6 +195,16 @@ function DictionaryDetail() { for (const d of pendingDraftsQuery.data ?? []) set.add(d.businessKey) return set }, [pendingDraftsQuery.data]) + /** + * Set для записей имеющих scheduled future version. Используется + * рендерить «Запланировано» badge per row в records table — user сразу видит + * у каких записей есть upcoming изменения. + */ + const scheduledByKey = useMemo(() => { + const set = new Set() + for (const bk of scheduledSummary.data?.scheduledBusinessKeys ?? []) set.add(bk) + return set + }, [scheduledSummary.data?.scheduledBusinessKeys]) const createMut = useCreateRecord(name) const updateMut = useUpdateRecord(name) // Approval Workflow v2 (D2=A): когда dict отмечен approvalRequired=true, @@ -1164,6 +1174,19 @@ function DictionaryDetail() { {t('dict.pendingReview.label')} ) : null} + {/* «Scheduled» badge: у этой записи есть future version + * (validFrom > now). User видит в общем списке какие + * записи скоро изменятся. CTA для перехода к этой дате + * — banner выше уже линкует на nearestValidFrom. */} + {scheduledByKey.has(r.businessKey) ? ( + + + {t('dict.scheduled.row.badge', { defaultValue: 'Запланировано' })} + + + ) : null} 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 b8702b6..881c769 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 @@ -228,4 +228,28 @@ public interface DictionaryRecordRepository extends JpaRepository allowedScopes, @Param("now") OffsetDateTime now, org.springframework.data.domain.Pageable pageable); + + /** + * Distinct businessKeys которые имеют scheduled future version + * (validFrom > now, validTo > now). Frontend mapping → render «Scheduled» + * badge per row в records table. + * + *

Один из ключей может появиться несколько раз если у dict'а есть + * несколько future versions того же businessKey — DISTINCT убирает дубли. + * + *

Capped на 500 keys в service слое — больше типичный admin не fetch'ит + * за раз (если scheduled > 500, banner показывает общий count). + */ + @Query(""" + SELECT DISTINCT r.businessKey FROM DictionaryRecord r + WHERE r.dictionaryId = :dictionaryId + AND r.dataScope IN :allowedScopes + AND r.validFrom > :now + AND r.validTo > :now + """) + List findScheduledBusinessKeys( + @Param("dictionaryId") UUID dictionaryId, + @Param("allowedScopes") Collection allowedScopes, + @Param("now") OffsetDateTime now, + org.springframework.data.domain.Pageable pageable); } 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 index abc8db8..b00881f 100644 --- 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 @@ -24,9 +24,15 @@ import java.util.List; public record ScheduledRecordsSummary( long count, OffsetDateTime nearestValidFrom, - List upcomingValidFroms) { + List upcomingValidFroms, + /** + * Distinct businessKeys имеющие future scheduled version. Frontend + * lookup-set для рендеринга «Scheduled» badge per row в records table. + * Capped 500 (если больше — banner показывает общий count). + */ + List scheduledBusinessKeys) { public static ScheduledRecordsSummary empty() { - return new ScheduledRecordsSummary(0L, null, List.of()); + return new ScheduledRecordsSummary(0L, null, List.of(), List.of()); } } 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 2f4368f..867d8e6 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 @@ -216,7 +216,15 @@ public class RecordReadService { List upcoming = recordRepository.findUpcomingValidFroms( def.getId(), allowedScopes, now, org.springframework.data.domain.PageRequest.of(0, 50)); - return new ScheduledRecordsSummary(count, nearest, upcoming); + + // Distinct businessKeys → frontend lookup-Set для «Scheduled» badge per row. + // Cap 500 — admin редко смотрит > 500 строк, banner показывает общий count + // (юзер всё равно увидит scheduled через time-travel или filter). + List scheduledKeys = recordRepository.findScheduledBusinessKeys( + def.getId(), allowedScopes, now, + org.springframework.data.domain.PageRequest.of(0, 500)); + + return new ScheduledRecordsSummary(count, nearest, upcoming, scheduledKeys); } public static class ScopeAccessDeniedException extends RuntimeException {