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
@@ -21,12 +21,17 @@ public record DictionaryResponse(
String bundle,
List<String> supportedLocales,
String defaultLocale,
Long recordCount,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
String createdBy,
String updatedBy) {
public static DictionaryResponse from(DictionaryDefinition d) {
return from(d, null);
}
public static DictionaryResponse from(DictionaryDefinition d, Long recordCount) {
return new DictionaryResponse(
d.getId(),
d.getName(),
@@ -38,6 +43,7 @@ public record DictionaryResponse(
d.getBundle(),
d.getSupportedLocales() == null ? List.of() : List.of(d.getSupportedLocales()),
d.getDefaultLocale(),
recordCount,
d.getCreatedAt(),
d.getUpdatedAt(),
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.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.DictionaryResponse;
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.RestController;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/dictionaries")
@@ -25,18 +31,24 @@ public class DictionaryDefinitionController {
private final DictionaryDefinitionService service;
private final ScopeContext scopeContext;
private final DictionaryRecordRepository recordRepository;
public DictionaryDefinitionController(
DictionaryDefinitionService service, ScopeContext scopeContext) {
DictionaryDefinitionService service,
ScopeContext scopeContext,
DictionaryRecordRepository recordRepository) {
this.service = service;
this.scopeContext = scopeContext;
this.recordRepository = recordRepository;
}
@GetMapping
public List<DictionaryResponse> list() {
Set<DataScope> allowedScopes = scopeContext.currentScopes();
Map<UUID, Long> counts = countsByDictionary(allowedScopes);
return service.findAll().stream()
.filter(d -> scopeContext.canAccess(d.getScope()))
.map(DictionaryResponse::from)
.map(d -> DictionaryResponse.from(d, counts.getOrDefault(d.getId(), 0L)))
.toList();
}
@@ -47,7 +59,17 @@ public class DictionaryDefinitionController {
throw OrdinisException.notFound(
"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