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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +94,25 @@ function DictionariesPage() {
|
|||||||
actions={createButton}
|
actions={createButton}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<div className="flex-1 max-w-md">
|
||||||
|
<SearchInput
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t('dict.list.search.placeholder')}
|
||||||
|
aria-label={t('dict.list.search.placeholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-carbon/70">
|
||||||
|
{t('dict.list.found', { shown: filtered.length, total: data.length })}
|
||||||
|
</span>
|
||||||
|
</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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{data.map((d) => (
|
{filtered.map((d) => (
|
||||||
<Link
|
<Link
|
||||||
key={d.id}
|
key={d.id}
|
||||||
to="/dictionaries/$name"
|
to="/dictionaries/$name"
|
||||||
@@ -86,16 +128,24 @@ function DictionariesPage() {
|
|||||||
{d.description && (
|
{d.description && (
|
||||||
<p className="text-sm text-carbon line-clamp-2 mb-3">{d.description}</p>
|
<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">
|
<div className="flex items-center justify-between gap-2 text-2xs uppercase tracking-label text-carbon/60">
|
||||||
<span>v{d.schemaVersion}</span>
|
<div className="flex gap-2 min-w-0">
|
||||||
|
<span className="truncate">v{d.schemaVersion}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{d.bundle}</span>
|
<span className="truncate">{d.bundle}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{d.supportedLocales.join(', ')}</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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DictionaryEditorDialog
|
<DictionaryEditorDialog
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
|
|||||||
+19
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -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(),
|
||||||
|
|||||||
+25
-3
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user