Merge pull request #530 from iflytek/fix/issue-51-profile-review-notifications

fix: admin notifications for profile review requests
This commit is contained in:
dongmucat 2026-06-17 11:08:10 +08:00 committed by GitHub
commit a23cbd84eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 294 additions and 16 deletions

View file

@ -16,6 +16,7 @@ import jakarta.validation.constraints.Min;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@ -78,7 +79,7 @@ public class NotificationController extends BaseApiController {
return ok("response.success.deleted", null);
}
@GetMapping("/sse")
@GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter sse(@RequestAttribute("userId") String userId) {
return sseEmitterManager.register(userId);
}
@ -113,6 +114,9 @@ public class NotificationController extends BaseApiController {
if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) {
return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId);
}
if ("PROFILE_REVIEW_SUBMITTED".equals(eventType) && entityId != null) {
return new NotificationTarget("PROFILE_REVIEW", entityId, "/dashboard/reviews?type=profile");
}
if ("PROMOTION_SUBMITTED".equals(eventType)) {
return new NotificationTarget("PROMOTION", entityId, "/dashboard/promotions");
}

View file

@ -30,6 +30,9 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Set<String> SKIP_PREFIXES = Set.of(
"/actuator", "/favicon.ico", "/assets/"
);
private static final Set<String> SKIP_SUFFIXES = Set.of(
"/sse"
);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@ -89,6 +92,11 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
return true;
}
}
for (String suffix : SKIP_SUFFIXES) {
if (uri.endsWith(suffix)) {
return true;
}
}
return false;
}

View file

@ -128,6 +128,22 @@ public class NotificationEventListener {
});
}
@Async("skillhubEventExecutor")
@TransactionalEventListener
public void onProfileReviewSubmitted(ProfileReviewSubmittedEvent event) {
String title = "Profile review submitted";
Map<String, Object> body = new LinkedHashMap<>();
body.put("profileReviewId", event.profileReviewId());
body.put("submitterId", event.submitterId());
body.put("fields", event.fields());
String json = toJson(body);
List<String> admins = recipientResolver.resolvePlatformUserAdmins();
for (String admin : admins.stream().distinct().toList()) {
dispatcher.dispatch(admin, NotificationCategory.REVIEW,
"PROFILE_REVIEW_SUBMITTED", title, json, "PROFILE_REVIEW", event.profileReviewId());
}
}
@Async("skillhubEventExecutor")
@TransactionalEventListener
public void onReviewApproved(ReviewApprovedEvent event) {

View file

@ -39,4 +39,14 @@ public class RecipientResolver {
List::copyOf
));
}
public List<String> resolvePlatformUserAdmins() {
return userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN"))
.stream()
.map(binding -> binding.getUserId())
.collect(java.util.stream.Collectors.collectingAndThen(
java.util.stream.Collectors.toCollection(LinkedHashSet::new),
List::copyOf
));
}
}

View file

@ -70,6 +70,28 @@ class NotificationControllerTest {
verify(notificationService).list(org.mockito.ArgumentMatchers.eq("user-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class));
}
@Test
void list_shouldExposeProfileReviewTargetRouteForSubmittedProfileReviewNotifications() {
Notification notification = notification(
15L,
NotificationCategory.REVIEW,
"PROFILE_REVIEW_SUBMITTED",
"{\"profileReviewId\":77,\"submitterId\":\"user-1\",\"fields\":[\"displayName\"]}",
"PROFILE_REVIEW",
77L
);
when(notificationService.list(org.mockito.ArgumentMatchers.eq("admin-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class)))
.thenReturn(new PageImpl<>(java.util.List.of(notification)));
PageResponse<NotificationResponse> page = controller.list("admin-1", "REVIEW", 0, 20).data();
assertThat(page.items()).singleElement().satisfies(item -> {
assertThat(item.targetType()).isEqualTo("PROFILE_REVIEW");
assertThat(item.targetId()).isEqualTo(77L);
assertThat(item.targetRoute()).isEqualTo("/dashboard/reviews?type=profile");
});
}
@Test
void list_shouldExposeSkillRouteForResolvedWorkflowNotifications() {
Notification notification = notification(

View file

@ -78,6 +78,29 @@ class RequestLoggingFilterTest {
assertThat(loggedMessages()).noneMatch(message -> message.contains("/actuator/health"));
}
@Test
void doFilterInternal_skipsSseEndpointsWithoutWrappingResponse()
throws ServletException, IOException {
RequestLoggingFilter filter = new RequestLoggingFilter();
attachAppender();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse");
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain filterChain = (req, res) -> {
assertThat(res).isSameAs(response);
res.setContentType("text/event-stream");
res.getWriter().write("event:connected\n");
res.getWriter().flush();
};
filter.doFilter(request, response, filterChain);
assertThat(response.getHeader("Content-Length")).isNull();
assertThat(response.getContentAsString()).isEqualTo("event:connected\n");
assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/notifications/sse"));
}
@Test
void doFilterInternal_logsCoreSummaryFields()
throws ServletException, IOException {

View file

@ -127,6 +127,21 @@ class NotificationEventListenerTest {
verify(dispatcher).dispatch(eq("admin-2"), any(), any(), any(), any(), any(), any());
}
@Test
void onProfileReviewSubmitted_shouldDispatchToPlatformUserAdmins() throws Exception {
when(objectMapper.writeValueAsString(any())).thenReturn("{}");
when(recipientResolver.resolvePlatformUserAdmins())
.thenReturn(List.of("user-admin-1", "super-admin-1", "user-admin-1"));
listener.onProfileReviewSubmitted(
new ProfileReviewSubmittedEvent(77L, "submitter-1", List.of("displayName")));
verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.REVIEW),
eq("PROFILE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("PROFILE_REVIEW"), eq(77L));
verify(dispatcher).dispatch(eq("user-admin-1"), any(), any(), any(), any(), any(), any());
verify(dispatcher).dispatch(eq("super-admin-1"), any(), any(), any(), any(), any(), any());
}
@Test
void onReviewApproved_shouldDispatchToSubmitter() throws Exception {
Skill skill = mockSkill(1L);

View file

@ -80,4 +80,20 @@ class RecipientResolverTest {
assertThat(result).containsExactly("skill-admin", "super-admin");
}
@Test
void resolvePlatformUserAdmins_shouldReturnUserAdminsAndSuperAdmins() {
UserRoleBinding userAdmin = mock(UserRoleBinding.class);
UserRoleBinding superAdmin = mock(UserRoleBinding.class);
UserRoleBinding duplicate = mock(UserRoleBinding.class);
when(userAdmin.getUserId()).thenReturn("user-admin");
when(superAdmin.getUserId()).thenReturn("super-admin");
when(duplicate.getUserId()).thenReturn("user-admin");
when(userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN")))
.thenReturn(List.of(userAdmin, superAdmin, duplicate));
List<String> result = resolver.resolvePlatformUserAdmins();
assertThat(result).containsExactly("user-admin", "super-admin");
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.domain.event;
import java.util.List;
public record ProfileReviewSubmittedEvent(Long profileReviewId, String submitterId, List<String> fields) {
public ProfileReviewSubmittedEvent {
fields = List.copyOf(fields);
}
}

View file

@ -3,10 +3,13 @@ package com.iflytek.skillhub.domain.user;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@ -29,19 +32,22 @@ public class UserProfileService {
private final ProfileModerationConfig moderationConfig;
private final ProfileFieldPolicyConfig fieldPolicyConfig;
private final AuditLogService auditLogService;
private final ApplicationEventPublisher eventPublisher;
public UserProfileService(UserAccountRepository userAccountRepository,
ProfileChangeRequestRepository changeRequestRepository,
ProfileModerationService moderationService,
ProfileModerationConfig moderationConfig,
ProfileFieldPolicyConfig fieldPolicyConfig,
AuditLogService auditLogService) {
AuditLogService auditLogService,
ApplicationEventPublisher eventPublisher) {
this.userAccountRepository = userAccountRepository;
this.changeRequestRepository = changeRequestRepository;
this.moderationService = moderationService;
this.moderationConfig = moderationConfig;
this.fieldPolicyConfig = fieldPolicyConfig;
this.auditLogService = auditLogService;
this.eventPublisher = eventPublisher;
}
/**
@ -112,8 +118,10 @@ public class UserProfileService {
// 5. Queue review changes
if (!reviewChanges.isEmpty()) {
cancelPendingRequests(userId);
saveChangeRequest(userId, reviewChanges, oldValues, ProfileChangeStatus.PENDING,
machineTag, null);
ProfileChangeRequest pendingRequest = saveChangeRequest(userId, reviewChanges, oldValues,
ProfileChangeStatus.PENDING, machineTag, null);
eventPublisher.publishEvent(new ProfileReviewSubmittedEvent(
pendingRequest.getId(), userId, List.copyOf(reviewChanges.keySet())));
}
// 6. Return appropriate result
@ -166,9 +174,9 @@ public class UserProfileService {
/**
* Persist a change request record for audit and review purposes.
*/
private void saveChangeRequest(String userId, Map<String, String> changes,
Map<String, String> oldValues, ProfileChangeStatus status,
String machineResult, String machineReason) {
private ProfileChangeRequest saveChangeRequest(String userId, Map<String, String> changes,
Map<String, String> oldValues, ProfileChangeStatus status,
String machineResult, String machineReason) {
ProfileChangeRequest request = new ProfileChangeRequest(
userId,
toJson(changes),
@ -177,7 +185,7 @@ public class UserProfileService {
machineResult,
machineReason
);
changeRequestRepository.save(request);
return changeRequestRepository.save(request);
}
private String toJson(Object obj) {

View file

@ -1,16 +1,21 @@
package com.iflytek.skillhub.domain.user;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
@ -41,9 +46,24 @@ class UserProfileServiceTest {
@Mock
private AuditLogService auditLogService;
@Mock
private ApplicationEventPublisher eventPublisher;
@InjectMocks
private UserProfileService userProfileService;
@BeforeEach
void setUp() {
AtomicLong ids = new AtomicLong(1L);
lenient().when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> {
ProfileChangeRequest request = invocation.getArgument(0);
if (request.getId() == null) {
setField(request, "id", ids.getAndIncrement());
}
return request;
});
}
// -- Helper --
private UserAccount testUser() {
@ -136,6 +156,32 @@ class UserProfileServiceTest {
verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
void updateProfile_humanReviewEnabled_shouldPublishProfileReviewSubmittedEvent() {
var user = testUser();
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
when(moderationConfig.machineReview()).thenReturn(false);
when(moderationConfig.humanReview()).thenReturn(true);
stubFieldPolicies(true);
when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING))
.thenReturn(List.of());
when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> {
ProfileChangeRequest request = invocation.getArgument(0);
setField(request, "id", 77L);
return request;
});
userProfileService.updateProfile(
"user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent");
var eventCaptor = ArgumentCaptor.forClass(ProfileReviewSubmittedEvent.class);
verify(eventPublisher).publishEvent(eventCaptor.capture());
ProfileReviewSubmittedEvent event = eventCaptor.getValue();
assertEquals(77L, event.profileReviewId());
assertEquals("user-1", event.submitterId());
assertEquals(List.of("displayName"), event.fields());
}
// ===== AC-P-005: Overwrite existing PENDING request =====
@Test
@ -221,4 +267,14 @@ class UserProfileServiceTest {
userProfileService.updateProfile(
"nonexistent", displayNameChange("Name"), "req-1", "127.0.0.1", "TestAgent"));
}
private static void setField(Object target, String fieldName, Object value) {
try {
Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException e) {
throw new AssertionError(e);
}
}
}

View file

@ -7,10 +7,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
class SseEmitterManagerTest {
@ -30,13 +34,19 @@ class SseEmitterManagerTest {
@Test
void register_shouldReturnEmitter() {
emitters.add(new TestEmitter());
TestEmitter testEmitter = new TestEmitter();
emitters.add(testEmitter);
SseEmitter emitter = manager.register("user-1");
assertNotNull(emitter);
assertEquals(1, manager.totalEmitters());
assertEquals(1, manager.emittersForUser("user-1"));
assertEquals(1, testEmitter.sentEventCount());
assertTrue(testEmitter.sentEventData(0).stream()
.anyMatch(value -> value.toString().contains("event:connected")));
assertTrue(testEmitter.sentEventData(0).contains("ok"));
assertTrue(testEmitter.isOpen());
}
@Test
@ -93,6 +103,27 @@ class SseEmitterManagerTest {
assertEquals(1, manager.emittersForUser("user-1"));
}
@Test
void push_shouldSendNotificationEventToRegisteredOpenEmitter() {
TestEmitter emitter = new TestEmitter();
emitters.add(emitter);
manager.register("user-1");
Map<String, Object> payload = Map.of(
"id", 42L,
"eventType", "PROFILE_REVIEW_SUBMITTED"
);
manager.push("user-1", payload);
assertEquals(2, emitter.sentEventCount());
assertTrue(emitter.sentEventData(1).stream()
.anyMatch(value -> value.toString().contains("event:notification")));
assertTrue(emitter.sentEventData(1).contains(payload));
assertTrue(emitter.isOpen());
assertEquals(1, manager.totalEmitters());
assertEquals(1, manager.emittersForUser("user-1"));
}
@Test
void heartbeat_shouldRemoveEmitterWhenSendFails() {
TestEmitter healthy = new TestEmitter();
@ -152,6 +183,8 @@ class SseEmitterManagerTest {
private boolean failAfterConnected;
private boolean throwOnComplete;
private int sendCount;
private boolean completed;
private final List<List<Object>> sentEvents = new ArrayList<>();
private TestEmitter() {
super(60_000L);
@ -173,6 +206,18 @@ class SseEmitterManagerTest {
errorCallback.accept(new IOException("boom-" + userId + "-" + errorCallbacks.incrementAndGet()));
}
boolean isOpen() {
return !completed;
}
int sentEventCount() {
return sentEvents.size();
}
List<Object> sentEventData(int index) {
return sentEvents.get(index);
}
@Override
public synchronized void onCompletion(Runnable callback) {
this.completionCallback = callback;
@ -193,6 +238,7 @@ class SseEmitterManagerTest {
if (throwOnComplete) {
throw new IllegalStateException("already complete");
}
completed = true;
completionCallback.run();
}
@ -202,6 +248,9 @@ class SseEmitterManagerTest {
if (failAfterConnected && sendCount > 1) {
throw new IOException("send failed");
}
sentEvents.add(builder.build().stream()
.map(ResponseBodyEmitter.DataWithMediaType::getData)
.toList());
}
}
}

View file

@ -81,4 +81,12 @@ test.describe('Review Management Pagination (Real API)', () => {
}
}
})
test('opens the profile review queue from the review type search param', async ({ page }) => {
await page.goto('/dashboard/reviews?type=profile')
await expect(page).toHaveURL(/\/dashboard\/reviews\?type=profile$/)
await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Profile Review Queue' })).toBeVisible()
})
})

View file

@ -9717,7 +9717,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["SseEmitter"];
"text/event-stream": components["schemas"]["SseEmitter"];
};
};
};
@ -9737,7 +9737,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["SseEmitter"];
"text/event-stream": components["schemas"]["SseEmitter"];
};
};
};
@ -9938,6 +9938,8 @@ export interface operations {
page?: number;
size?: number;
filter?: string;
q?: string;
namespace?: string;
};
header?: never;
path?: never;
@ -9962,6 +9964,8 @@ export interface operations {
page?: number;
size?: number;
filter?: string;
q?: string;
namespace?: string;
};
header?: never;
path?: never;

View file

@ -306,6 +306,9 @@ const dashboardReviewsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard/reviews',
beforeLoad: requireAuth,
validateSearch: (search: Record<string, unknown>): { type?: 'skill' | 'profile' } => ({
type: search.type === 'skill' || search.type === 'profile' ? search.type : undefined,
}),
component: ReviewsPage,
})

View file

@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { createElement } from 'react'
const useSearchMock = vi.fn()
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
useSearch: () => useSearchMock(),
}))
vi.mock('lucide-react', () => ({
@ -119,6 +121,7 @@ describe('ReviewsPage', () => {
data: [],
isLoading: false,
})
useSearchMock.mockReturnValue({})
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
if (!enabled) {
return { data: null, isLoading: false }
@ -180,4 +183,15 @@ describe('ReviewsPage', () => {
expect(paginationProps[0]?.page).toBe(0)
expect(paginationProps[0]?.totalPages).toBe(1)
})
it('uses the profile review tab when the review type search param requests it', () => {
hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN' || role === 'USER_ADMIN')
userMock.platformRoles = ['SUPER_ADMIN']
useSearchMock.mockReturnValue({ type: 'profile' })
renderToStaticMarkup(createElement(ReviewsPage))
expect(useReviewListMock).toHaveBeenCalled()
expect(useReviewListMock.mock.calls.every((call) => call[5] === false)).toBe(true)
})
})

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { FileCheck2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
@ -38,6 +38,7 @@ const PAGE_SIZE = 20
export function ReviewsPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate()
const search = useSearch({ from: '/dashboard/reviews' })
const { hasRole, user } = useAuth()
const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
const [pages, setPages] = useState<Record<ReviewStatus, number>>({
@ -56,6 +57,9 @@ export function ReviewsPage() {
// Determine default top-level tab
const defaultType = isSkillAdmin ? 'skill' : 'profile'
const requestedType = search.type === 'skill' || search.type === 'profile' ? search.type : undefined
const activeType = showTypeTabs ? requestedType ?? defaultType : defaultType
const skillReviewEnabled = hasGlobalReviewAccess && isSkillAdmin && activeType === 'skill'
useEffect(() => {
if (hasGlobalReviewAccess || isLoadingNamespaces) {
@ -70,9 +74,9 @@ export function ReviewsPage() {
void navigate({ to: '/dashboard', replace: true })
}, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate])
const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'PENDING')
const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'APPROVED')
const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'REJECTED')
const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'PENDING')
const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'APPROVED')
const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'REJECTED')
const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language)
@ -93,6 +97,14 @@ export function ReviewsPage() {
})
}
function handleTypeChange(value: string) {
void navigate({
to: '/dashboard/reviews',
search: { type: value === 'profile' ? 'profile' : 'skill' },
replace: true,
})
}
function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) {
const currentPage = pages[status]
return (
@ -249,7 +261,7 @@ export function ReviewsPage() {
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
{showTypeTabs ? (
<Tabs defaultValue={defaultType}>
<Tabs value={activeType} onValueChange={handleTypeChange}>
<TabsList className="gap-2 rounded-2xl border-b-0 bg-muted/80 p-1 shadow-sm">
<TabsTrigger
value="skill"