feat(admin-ui): поиск справочников + счётчик активных записей

Backend:
- DictionaryRecordRepository.countActiveGroupedByDictionary — один GROUP BY
  по всем справочникам с фильтром по scope, без N+1.
- DictionaryResponse.recordCount (nullable) + factory from(d, count).
- DictionaryDefinitionController передаёт счётчики в list/get,
  фильтрованные по currentScopes() пользователя.

Frontend:
- SearchInput над сеткой карточек, client-side filter по
  name/displayName/description (case-insensitive, useDeferredValue).
- Badge "N записей" (i18n plural ru/en) в углу карточки.
- "Показано N из M" рядом с поиском.
- EmptyState когда поиск пустой.
This commit is contained in:
Zimin A.N.
2026-05-06 08:46:45 +03:00
parent b57bc9ef82
commit 171d049fc5
6 changed files with 141 additions and 31 deletions
+1
View File
@@ -35,6 +35,7 @@ export type DictionaryDefinition = {
bundle: string bundle: string
supportedLocales: string[] supportedLocales: string[]
defaultLocale: string defaultLocale: string
recordCount?: number
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
+12
View File
@@ -61,6 +61,13 @@ i18n
'outbox.dlq.empty': 'DLQ пуста — все события публикуются успешно', 'outbox.dlq.empty': 'DLQ пуста — все события публикуются успешно',
'header.scope': 'Доступный scope', 'header.scope': 'Доступный scope',
'dict.list.subtitle': 'Каталоги НСИ ЦУОД ОДХ', 'dict.list.subtitle': 'Каталоги НСИ ЦУОД ОДХ',
'dict.list.search.placeholder': 'Поиск по названию, коду или описанию',
'dict.list.search.empty': 'Ничего не найдено',
'dict.list.found': 'Показано {{shown}} из {{total}}',
'dict.list.recordCount_one': '{{count}} запись',
'dict.list.recordCount_few': '{{count}} записи',
'dict.list.recordCount_many': '{{count}} записей',
'dict.list.recordCount_other': '{{count}} записей',
'dict.empty': 'В этом справочнике пока нет записей', 'dict.empty': 'В этом справочнике пока нет записей',
'dict.col.businessKey': 'Бизнес-ключ', 'dict.col.businessKey': 'Бизнес-ключ',
'dict.col.scope': 'Scope', 'dict.col.scope': 'Scope',
@@ -227,6 +234,11 @@ i18n
'outbox.dlq.empty': 'DLQ empty — all events publish successfully', 'outbox.dlq.empty': 'DLQ empty — all events publish successfully',
'header.scope': 'Allowed scope', 'header.scope': 'Allowed scope',
'dict.list.subtitle': 'Reference data catalogues', 'dict.list.subtitle': 'Reference data catalogues',
'dict.list.search.placeholder': 'Search by name, code or description',
'dict.list.search.empty': 'Nothing found',
'dict.list.found': 'Showing {{shown}} of {{total}}',
'dict.list.recordCount_one': '{{count}} record',
'dict.list.recordCount_other': '{{count}} records',
'dict.empty': 'No records in this dictionary yet', 'dict.empty': 'No records in this dictionary yet',
'dict.col.businessKey': 'Business key', 'dict.col.businessKey': 'Business key',
'dict.col.scope': 'Scope', 'dict.col.scope': 'Scope',
@@ -1,20 +1,45 @@
import { useState } from 'react' import { useDeferredValue, useMemo, useState } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Alert, Badge, Button, EmptyState, LoadingBlock, PageHeader } from '@nstart/ui' import {
Alert,
Badge,
Button,
EmptyState,
LoadingBlock,
PageHeader,
SearchInput,
} from '@nstart/ui'
import { PlusIcon } from '@phosphor-icons/react' import { PlusIcon } from '@phosphor-icons/react'
import { useDictionaries } from '@/api/queries' import { useDictionaries } from '@/api/queries'
import type { DictionaryDefinition } from '@/api/client'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog' import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
export const Route = createFileRoute('/dictionaries/')({ export const Route = createFileRoute('/dictionaries/')({
component: DictionariesPage, component: DictionariesPage,
}) })
const matchesQuery = (d: DictionaryDefinition, q: string): boolean => {
if (!q) return true
const haystack = [d.name, d.displayName ?? '', d.description ?? '']
.join(' ')
.toLowerCase()
return haystack.includes(q)
}
function DictionariesPage() { function DictionariesPage() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const { data, isLoading, error } = useDictionaries() const { data, isLoading, error } = useDictionaries()
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
const filtered = useMemo(() => {
if (!data) return []
const q = deferredQuery.trim().toLowerCase()
return data.filter((d) => matchesQuery(d, q))
}, [data, deferredQuery])
const createButton = ( const createButton = (
<Button <Button
@@ -69,34 +94,59 @@ function DictionariesPage() {
actions={createButton} actions={createButton}
/> />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="flex flex-col sm:flex-row sm:items-center gap-3">
{data.map((d) => ( <div className="flex-1 max-w-md">
<Link <SearchInput
key={d.id} value={query}
to="/dictionaries/$name" onChange={(e) => setQuery(e.target.value)}
params={{ name: d.name }} placeholder={t('dict.list.search.placeholder')}
className="block bg-white border border-regolith rounded-lg p-4 transition shadow-card hover:shadow-hover hover:border-ultramarain/40" aria-label={t('dict.list.search.placeholder')}
> />
<div className="flex items-start justify-between mb-2 gap-2"> </div>
<h2 className="font-primary text-base text-ultramarain"> <span className="text-sm text-carbon/70">
{d.displayName ?? d.name} {t('dict.list.found', { shown: filtered.length, total: data.length })}
</h2> </span>
<Badge variant="info">{d.scope}</Badge>
</div>
{d.description && (
<p className="text-sm text-carbon line-clamp-2 mb-3">{d.description}</p>
)}
<div className="flex gap-2 text-2xs uppercase tracking-label text-carbon/60">
<span>v{d.schemaVersion}</span>
<span>·</span>
<span>{d.bundle}</span>
<span>·</span>
<span>{d.supportedLocales.join(', ')}</span>
</div>
</Link>
))}
</div> </div>
{filtered.length === 0 ? (
<EmptyState title={t('dict.list.search.empty')} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.map((d) => (
<Link
key={d.id}
to="/dictionaries/$name"
params={{ name: d.name }}
className="block bg-white border border-regolith rounded-lg p-4 transition shadow-card hover:shadow-hover hover:border-ultramarain/40"
>
<div className="flex items-start justify-between mb-2 gap-2">
<h2 className="font-primary text-base text-ultramarain">
{d.displayName ?? d.name}
</h2>
<Badge variant="info">{d.scope}</Badge>
</div>
{d.description && (
<p className="text-sm text-carbon line-clamp-2 mb-3">{d.description}</p>
)}
<div className="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60">
<div className="flex gap-2 min-w-0">
<span className="truncate">v{d.schemaVersion}</span>
<span>·</span>
<span className="truncate">{d.bundle}</span>
<span>·</span>
<span className="truncate">{d.supportedLocales.join(', ')}</span>
</div>
{typeof d.recordCount === 'number' && (
<Badge variant="neutral">
{t('dict.list.recordCount', { count: d.recordCount })}
</Badge>
)}
</div>
</Link>
))}
</div>
)}
<DictionaryEditorDialog <DictionaryEditorDialog
open={createOpen} open={createOpen}
mode={{ kind: 'create' }} mode={{ kind: 'create' }}
@@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -66,4 +67,22 @@ public interface DictionaryRecordRepository extends JpaRepository<DictionaryReco
@Param("at") OffsetDateTime at, @Param("at") OffsetDateTime at,
@Param("fieldName") String fieldName, @Param("fieldName") String fieldName,
@Param("fieldValue") String fieldValue); @Param("fieldValue") String fieldValue);
/**
* Активные записи (validFrom <= now < validTo), сгруппированные по словарю и
* отфильтрованные по доступным consumer'у scope'ам. Один запрос на весь список
* справочников — без N+1.
*
* @return массивы [dictionaryId UUID, count Long]
*/
@Query("""
SELECT r.dictionaryId, COUNT(r) FROM DictionaryRecord r
WHERE r.dataScope IN :allowedScopes
AND r.validFrom <= :at
AND r.validTo > :at
GROUP BY r.dictionaryId
""")
List<Object[]> countActiveGroupedByDictionary(
@Param("allowedScopes") Collection<DataScope> allowedScopes,
@Param("at") OffsetDateTime at);
} }
@@ -21,12 +21,17 @@ public record DictionaryResponse(
String bundle, String bundle,
List<String> supportedLocales, List<String> supportedLocales,
String defaultLocale, String defaultLocale,
Long recordCount,
OffsetDateTime createdAt, OffsetDateTime createdAt,
OffsetDateTime updatedAt, OffsetDateTime updatedAt,
String createdBy, String createdBy,
String updatedBy) { String updatedBy) {
public static DictionaryResponse from(DictionaryDefinition d) { public static DictionaryResponse from(DictionaryDefinition d) {
return from(d, null);
}
public static DictionaryResponse from(DictionaryDefinition d, Long recordCount) {
return new DictionaryResponse( return new DictionaryResponse(
d.getId(), d.getId(),
d.getName(), d.getName(),
@@ -38,6 +43,7 @@ public record DictionaryResponse(
d.getBundle(), d.getBundle(),
d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()), d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()),
d.getDefaultLocale(), d.getDefaultLocale(),
recordCount,
d.getCreatedAt(), d.getCreatedAt(),
d.getUpdatedAt(), d.getUpdatedAt(),
d.getCreatedBy(), d.getCreatedBy(),
@@ -2,6 +2,7 @@ package cloud.nstart.terravault.ordinis.restapi.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.domain.record.DictionaryRecordRepository;
import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest; import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse; import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
@@ -17,7 +18,12 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@RestController @RestController
@RequestMapping("/api/v1/dictionaries") @RequestMapping("/api/v1/dictionaries")
@@ -25,18 +31,24 @@ public class DictionaryDefinitionController {
private final DictionaryDefinitionService service; private final DictionaryDefinitionService service;
private final ScopeContext scopeContext; private final ScopeContext scopeContext;
private final DictionaryRecordRepository recordRepository;
public DictionaryDefinitionController( public DictionaryDefinitionController(
DictionaryDefinitionService service, ScopeContext scopeContext) { DictionaryDefinitionService service,
ScopeContext scopeContext,
DictionaryRecordRepository recordRepository) {
this.service = service; this.service = service;
this.scopeContext = scopeContext; this.scopeContext = scopeContext;
this.recordRepository = recordRepository;
} }
@GetMapping @GetMapping
public List<DictionaryResponse> list() { public List<DictionaryResponse> list() {
Set<DataScope> allowedScopes = scopeContext.currentScopes();
Map<UUID, Long> counts = countsByDictionary(allowedScopes);
return service.findAll().stream() return service.findAll().stream()
.filter(d -> scopeContext.canAccess(d.getScope())) .filter(d -> scopeContext.canAccess(d.getScope()))
.map(DictionaryResponse::from) .map(d -> DictionaryResponse.from(d, counts.getOrDefault(d.getId(), 0L)))
.toList(); .toList();
} }
@@ -47,7 +59,17 @@ public class DictionaryDefinitionController {
throw OrdinisException.notFound( throw OrdinisException.notFound(
"dictionary_not_found", "Dictionary not found: " + name); "dictionary_not_found", "Dictionary not found: " + name);
} }
return DictionaryResponse.from(d); Map<UUID, Long> counts = countsByDictionary(scopeContext.currentScopes());
return DictionaryResponse.from(d, counts.getOrDefault(d.getId(), 0L));
}
private Map<UUID, Long> countsByDictionary(Set<DataScope> allowedScopes) {
Map<UUID, Long> counts = new HashMap<>();
for (Object[] row :
recordRepository.countActiveGroupedByDictionary(allowedScopes, OffsetDateTime.now())) {
counts.put((UUID) row[0], (Long) row[1]);
}
return counts;
} }
@PostMapping @PostMapping