feat(records): «Запланировано» badge per row для scheduled future versions
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<String> (capped 500) - Repo method findScheduledBusinessKeys (DISTINCT subquery) - Single fetch вместо N+1 per-row queries Frontend: - scheduledByKey Set<string> построен из 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».
This commit is contained in:
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
+24
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-1
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user