diff --git a/web/src/features/review/use-review-detail.ts b/web/src/features/review/use-review-detail.ts index 7d4a7131..fee5166f 100644 --- a/web/src/features/review/use-review-detail.ts +++ b/web/src/features/review/use-review-detail.ts @@ -22,7 +22,7 @@ export function useReviewDetail(taskId: number) { }) } -export function useApproveReview() { +export function useApproveReview(callbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) { const queryClient = useQueryClient() return useMutation({ @@ -31,11 +31,13 @@ export function useApproveReview() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reviews'] }) queryClient.invalidateQueries({ queryKey: ['governance'] }) + callbacks?.onSuccess?.() }, + onError: callbacks?.onError, }) } -export function useRejectReview() { +export function useRejectReview(callbacks?: { onSuccess?: () => void; onError?: (error: Error) => void }) { const queryClient = useQueryClient() return useMutation({ @@ -44,6 +46,8 @@ export function useRejectReview() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reviews'] }) queryClient.invalidateQueries({ queryKey: ['governance'] }) + callbacks?.onSuccess?.() }, + onError: callbacks?.onError, }) } diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index 911ca246..0aa27c83 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -17,8 +17,24 @@ export function ReviewDetailPage() { const taskId = Number(id) const { data: review, isLoading } = useReviewDetail(taskId) - const approveMutation = useApproveReview() - const rejectMutation = useRejectReview() + const approveMutation = useApproveReview({ + onSuccess: () => { + toast.success(t('review.approveSuccess')) + navigate({ to: '/dashboard/reviews' }) + }, + onError: (error) => { + toast.error(t('review.approveFailed'), resolveReviewActionErrorDescription(error)) + }, + }) + const rejectMutation = useRejectReview({ + onSuccess: () => { + toast.success(t('review.rejectSuccess')) + navigate({ to: '/dashboard/reviews' }) + }, + onError: (error) => { + toast.error(t('review.rejectFailed'), resolveReviewActionErrorDescription(error)) + }, + }) const [comment, setComment] = useState('') const [showRejectForm, setShowRejectForm] = useState(false) @@ -30,18 +46,7 @@ export function ReviewDetailPage() { } const handleApprove = async () => { - approveMutation.mutate( - { taskId, comment: comment || undefined }, - { - onSuccess: () => { - toast.success(t('review.approveSuccess')) - navigate({ to: '/dashboard/reviews' }) - }, - onError: (error) => { - toast.error(t('review.approveFailed'), resolveReviewActionErrorDescription(error)) - }, - } - ) + approveMutation.mutate({ taskId, comment: comment || undefined }) } const handleReject = async () => { @@ -49,18 +54,7 @@ export function ReviewDetailPage() { toast.error(t('review.rejectReasonRequired')) return } - rejectMutation.mutate( - { taskId, comment }, - { - onSuccess: () => { - toast.success(t('review.rejectSuccess')) - navigate({ to: '/dashboard/reviews' }) - }, - onError: (error) => { - toast.error(t('review.rejectFailed'), resolveReviewActionErrorDescription(error)) - }, - } - ) + rejectMutation.mutate({ taskId, comment }) } if (isLoading) { diff --git a/web/src/shared/components/language-switcher.tsx b/web/src/shared/components/language-switcher.tsx index 73f3a017..6a7d1e39 100644 --- a/web/src/shared/components/language-switcher.tsx +++ b/web/src/shared/components/language-switcher.tsx @@ -7,7 +7,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/shared/ui/dropdown-menu' -import { Globe } from 'lucide-react' +import { ChevronDown, Globe } from 'lucide-react' interface LanguageSwitcherProps { className?: string @@ -35,18 +35,22 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) { - + {languages.map((lang) => ( changeLanguage(lang.code)} - className={currentLangCode === lang.code ? 'bg-accent' : ''} + className={cn( + 'cursor-pointer rounded-md px-3 py-2', + currentLangCode === lang.code ? 'bg-accent' : '' + )} > {lang.name} diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index 508e2043..5234e9fa 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -1,15 +1,9 @@ +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' import { cn } from '@/shared/lib/utils' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/shared/ui/dropdown-menu' interface User { displayName: string @@ -25,12 +19,48 @@ interface UserMenuProps { export function UserMenu({ user, triggerClassName }: UserMenuProps) { const { t } = useTranslation() const queryClient = useQueryClient() + const rootRef = useRef(null) + const closeTimerRef = useRef(null) + const [isHovered, setIsHovered] = useState(false) + const [isClickOpen, setIsClickOpen] = useState(false) const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false const isReviewer = hasRole('SKILL_ADMIN') || hasRole('NAMESPACE_ADMIN') || hasRole('SUPER_ADMIN') const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN') const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN') + const open = isHovered || isClickOpen + + const clearCloseTimer = () => { + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current) + closeTimerRef.current = null + } + } + + useEffect(() => { + if (!open) { + return + } + + const handlePointerDown = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsHovered(false) + setIsClickOpen(false) + } + } + + document.addEventListener('mousedown', handlePointerDown) + return () => { + document.removeEventListener('mousedown', handlePointerDown) + } + }, [open]) + + useEffect(() => { + return () => { + clearCloseTimer() + } + }, []) const handleLogout = async () => { try { @@ -44,96 +74,120 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { } } + const closeMenu = () => { + clearCloseTimer() + setIsHovered(false) + setIsClickOpen(false) + } + + const handleMouseEnter = () => { + clearCloseTimer() + setIsHovered(true) + } + + const handleMouseLeave = () => { + clearCloseTimer() + closeTimerRef.current = window.setTimeout(() => { + setIsHovered(false) + closeTimerRef.current = null + }, 120) + } + + const menuItemClassName = + 'block w-full rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground' + return ( - - - - - - - - {t('user.menu.dashboard')} - - - - - {t('user.menu.mySkills')} - - - - - {t('user.menu.myNamespaces')} - - - - - {t('user.menu.governance')} - - - - - {t('user.menu.stars')} - - - {isReviewer && ( - - - {t('user.menu.reviews')} - - +
+ + {open ? ( +
+
+ + {t('user.menu.dashboard')} - - )} - {isSkillAdmin && ( - - - {t('user.menu.reports')} + + {t('user.menu.mySkills')} - - )} - {(isUserAdmin || isAuditor) && } - {isUserAdmin && ( - - - {t('user.menu.users')} + + {t('user.menu.myNamespaces')} - - )} - {isAuditor && ( - - - {t('user.menu.auditLog')} + + {t('user.menu.governance')} - - )} - - - - {t('user.menu.security')} - - - - - {t('user.menu.logout')} - - - + + {t('user.menu.stars')} + + {isReviewer ? ( + + {t('user.menu.reviews')} + + ) : null} + {isSkillAdmin ? ( + + {t('user.menu.promotions')} + + ) : null} + {isSkillAdmin ? ( + + {t('user.menu.reports')} + + ) : null} + {isUserAdmin || isAuditor ?
: null} + {isUserAdmin ? ( + + {t('user.menu.users')} + + ) : null} + {isAuditor ? ( + + {t('user.menu.auditLog')} + + ) : null} +
+ + {t('user.menu.security')} + +
+ +
+
+ ) : null} +
) }