Merge branch 'feat/scheduled-badge-per-row' into 'main'

feat(records): «Запланировано» badge per row для scheduled future versions

See merge request 2-6/2-6-4/terravault/ordinis!232
This commit is contained in:
Александр Зимин
2026-05-16 09:52:00 +00:00
6 changed files with 74 additions and 3 deletions
+6
View File
@@ -791,4 +791,10 @@ export type ScheduledRecordsSummary = {
nearestValidFrom: string | null nearestValidFrom: string | null
/** Top-50 sorted asc upcoming validFrom ISO timestamps — для TimeTravel marks. */ /** Top-50 sorted asc upcoming validFrom ISO timestamps — для TimeTravel marks. */
upcomingValidFroms: string[] upcomingValidFroms: string[]
/**
* Distinct businessKeys имеющие future scheduled version. Frontend строит
* Set<string> для O(1) lookup и рендерит «Scheduled» badge per row.
* Capped 500 (если больше — banner с count'ом всё равно info'нит).
*/
scheduledBusinessKeys: string[]
} }
+4
View File
@@ -368,6 +368,8 @@ i18n
'dict.scheduled.hint_other': 'dict.scheduled.hint_other':
'Запланировано {{count}} записей, ближайшая на {{date}}', 'Запланировано {{count}} записей, ближайшая на {{date}}',
'dict.scheduled.cta': 'Перейти к этой дате', 'dict.scheduled.cta': 'Перейти к этой дате',
'dict.scheduled.row.badge': 'Запланировано',
'dict.scheduled.row.tooltip': 'У записи есть запланированная будущая версия',
'dict.col.businessKey': 'Бизнес-ключ', 'dict.col.businessKey': 'Бизнес-ключ',
'dict.col.scope': 'Scope', 'dict.col.scope': 'Scope',
'dict.col.validFrom': 'Действует с', 'dict.col.validFrom': 'Действует с',
@@ -1185,6 +1187,8 @@ i18n
'dict.scheduled.hint_other': 'dict.scheduled.hint_other':
'{{count}} records scheduled, earliest on {{date}}', '{{count}} records scheduled, earliest on {{date}}',
'dict.scheduled.cta': 'Jump to that 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.businessKey': 'Business key',
'dict.col.scope': 'Scope', 'dict.col.scope': 'Scope',
'dict.col.validFrom': 'Valid from', 'dict.col.validFrom': 'Valid from',
@@ -195,6 +195,16 @@ function DictionaryDetail() {
for (const d of pendingDraftsQuery.data ?? []) set.add(d.businessKey) for (const d of pendingDraftsQuery.data ?? []) set.add(d.businessKey)
return set return set
}, [pendingDraftsQuery.data]) }, [pendingDraftsQuery.data])
/**
* Set<businessKey> для записей имеющих scheduled future version. Используется
* рендерить «Запланировано» badge per row в records table — user сразу видит
* у каких записей есть upcoming изменения.
*/
const scheduledByKey = useMemo(() => {
const set = new Set<string>()
for (const bk of scheduledSummary.data?.scheduledBusinessKeys ?? []) set.add(bk)
return set
}, [scheduledSummary.data?.scheduledBusinessKeys])
const createMut = useCreateRecord(name) const createMut = useCreateRecord(name)
const updateMut = useUpdateRecord(name) const updateMut = useUpdateRecord(name)
// Approval Workflow v2 (D2=A): когда dict отмечен approvalRequired=true, // Approval Workflow v2 (D2=A): когда dict отмечен approvalRequired=true,
@@ -1164,6 +1174,19 @@ function DictionaryDetail() {
<Badge variant="warning">{t('dict.pendingReview.label')}</Badge> <Badge variant="warning">{t('dict.pendingReview.label')}</Badge>
</span> </span>
) : null} ) : null}
{/* «Scheduled» badge: у этой записи есть future version
* (validFrom > now). User видит в общем списке какие
* записи скоро изменятся. CTA для перехода к этой дате
* — banner выше уже линкует на nearestValidFrom. */}
{scheduledByKey.has(r.businessKey) ? (
<span title={t('dict.scheduled.row.tooltip', {
defaultValue: 'У записи есть запланированная будущая версия',
})}>
<Badge variant="info">
{t('dict.scheduled.row.badge', { defaultValue: 'Запланировано' })}
</Badge>
</span>
) : null}
</div> </div>
</TableCell> </TableCell>
<TableCell hideBelow="md"> <TableCell hideBelow="md">
@@ -228,4 +228,28 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
@Param("allowedScopes") Collection<DataScope> allowedScopes, @Param("allowedScopes") Collection<DataScope> allowedScopes,
@Param("now") OffsetDateTime now, @Param("now") OffsetDateTime now,
org.springframework.data.domain.Pageable pageable); org.springframework.data.domain.Pageable pageable);
/**
* Distinct businessKeys которые имеют scheduled future version
* (validFrom > now, validTo > now). Frontend mapping → render «Scheduled»
* badge per row в records table.
*
* <p>Один из ключей может появиться несколько раз если у dict'а есть
* несколько future versions того же businessKey — DISTINCT убирает дубли.
*
* <p>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<String> findScheduledBusinessKeys(
@Param("dictionaryId") UUID dictionaryId,
@Param("allowedScopes") Collection<DataScope> allowedScopes,
@Param("now") OffsetDateTime now,
org.springframework.data.domain.Pageable pageable);
} }
@@ -24,9 +24,15 @@ import java.util.List;
public record ScheduledRecordsSummary( public record ScheduledRecordsSummary(
long count, long count,
OffsetDateTime nearestValidFrom, OffsetDateTime nearestValidFrom,
List<OffsetDateTime> upcomingValidFroms) { List<OffsetDateTime> upcomingValidFroms,
/**
* Distinct businessKeys имеющие future scheduled version. Frontend
* lookup-set для рендеринга «Scheduled» badge per row в records table.
* Capped 500 (если больше — banner показывает общий count).
*/
List<String> scheduledBusinessKeys) {
public static ScheduledRecordsSummary empty() { public static ScheduledRecordsSummary empty() {
return new ScheduledRecordsSummary(0L, null, List.of()); return new ScheduledRecordsSummary(0L, null, List.of(), List.of());
} }
} }
@@ -216,7 +216,15 @@ public class RecordReadService {
List<OffsetDateTime> upcoming = recordRepository.findUpcomingValidFroms( List<OffsetDateTime> upcoming = recordRepository.findUpcomingValidFroms(
def.getId(), allowedScopes, now, def.getId(), allowedScopes, now,
org.springframework.data.domain.PageRequest.of(0, 50)); 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<String> 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 { public static class ScopeAccessDeniedException extends RuntimeException {