Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,25 @@ Deletes a user.

---

### `GET /contributions/{group_id}/summary`

Group-level annotator/reviewer contribution totals. Used by the **User contributions** admin page.

**Path params:** `group_id` — group UUID.

**Query params (optional)**

| Param | Type | Description |
|---|---|---|
| `start_date` | `YYYY-MM-DD` | First day of the range (**inclusive**) |
| `end_date` | `YYYY-MM-DD` | Last day of the range (**inclusive**) |

Omit both params for all-time (overall) totals.

**Response** `GroupContributionSummaryResponse` (see `src/types/contributions.ts`).

---

### `GET /tasks/scriptclassification/{userId}/contributions`

Returns a user's contribution summary for a given date range.
Expand Down
78 changes: 40 additions & 38 deletions src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ChevronLeft,
ChevronRight,
Settings,
BarChart3,
} from 'lucide-react'
import { cn, getInitials, getRoleTranslationKey } from '@/lib/utils'
import { useAuth } from '@/features/auth'
Expand All @@ -28,6 +29,13 @@ interface NavItem {
roles: UserRole[]
}

const CONTRIBUTIONS_ROLES: UserRole[] = [
UserRole.Admin,
UserRole.Annotator,
UserRole.Reviewer,
UserRole.FinalReviewer,
]

const navItems: NavItem[] = [
{
labelKey: 'nav.dashboard',
Expand All @@ -53,6 +61,12 @@ const navItems: NavItem[] = [
icon: Package,
roles: [UserRole.Admin],
},
{
labelKey: 'nav.userContributions',
href: '/admin/user-contributions',
icon: BarChart3,
roles: CONTRIBUTIONS_ROLES,
},
]

export function Sidebar() {
Expand All @@ -62,7 +76,6 @@ export function Sidebar() {
const [settingsOpen, setSettingsOpen] = useState(false)
const sidebarRef = useRef<HTMLElement>(null)

// Close settings when clicking outside sidebar
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
Expand All @@ -80,8 +93,8 @@ export function Sidebar() {

if (!currentUser) return null

const filteredNavItems = navItems.filter((item) =>
currentUser.role && item.roles.includes(currentUser.role)
const filteredNavItems = navItems.filter(
(item) => currentUser.role && item.roles.includes(currentUser.role)
)

const handleLogout = () => {
Expand All @@ -92,6 +105,15 @@ export function Sidebar() {
setSettingsOpen((prev) => !prev)
}

const linkClass = ({ isActive }: { isActive: boolean }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
sidebarCollapsed && 'justify-center px-2'
)

return (
<aside
ref={sidebarRef}
Expand All @@ -100,7 +122,6 @@ export function Sidebar() {
sidebarCollapsed ? 'w-16' : 'w-60'
)}
>
{/* Logo / Brand */}
<div className="flex h-16 items-center justify-between px-4">
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -129,46 +150,26 @@ export function Sidebar() {

<Separator className="bg-sidebar-border" />

{/* Navigation */}
<nav className="flex-1 space-y-1 p-2">
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
{filteredNavItems.map((item) => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
sidebarCollapsed && 'justify-center px-2'
)
}
>
<NavLink key={item.href} to={item.href} className={linkClass}>
<item.icon className="h-5 w-5 shrink-0" />
{!sidebarCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
))}
</nav>
<Separator className="bg-sidebar-border" />

{/* User Profile & Settings */}
<div className="p-2">
{/* Settings Panel - Animated */}
<div
className={cn(
'overflow-hidden transition-all duration-300 ease-out',
settingsOpen && !sidebarCollapsed ? 'max-h-40 opacity-100 mb-2' : 'max-h-0 opacity-0'
)}
>
<div className="flex flex-col gap-2 rounded-lg bg-sidebar-accent/50 p-2">
{/* Language Toggle */}
<LanguageToggle />

{/* Theme Toggle */}
<ThemeToggle />

{/* Logout Button */}
<Button
variant="ghost"
size="sm"
Expand All @@ -181,7 +182,6 @@ export function Sidebar() {
</div>
</div>

{/* Profile Row */}
<div
className={cn(
'flex items-center gap-3 rounded-lg p-2',
Expand All @@ -204,17 +204,19 @@ export function Sidebar() {
</p>
</div>
)}
{!sidebarCollapsed && <Button
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 shrink-0 text-muted-foreground hover:text-sidebar-foreground transition-transform duration-200',
settingsOpen && 'rotate-90'
)}
onClick={toggleSettings}
>
<Settings className="h-4 w-4" />
</Button>}
{!sidebarCollapsed && (
<Button
variant="ghost"
size="icon"
className={cn(
'h-8 w-8 shrink-0 text-muted-foreground hover:text-sidebar-foreground transition-transform duration-200',
settingsOpen && 'rotate-90'
)}
onClick={toggleSettings}
>
<Settings className="h-4 w-4" />
</Button>
)}
</div>
</div>
</aside>
Expand Down
11 changes: 11 additions & 0 deletions src/features/admin/api/contributions/contribution-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const contributionKeys = {
all: ['contributions'] as const,
summaryPairs: () => [...contributionKeys.all, 'summary-pair'] as const,
summaryPair: (groupId: string, periodStart: string, periodEnd: string) =>
[...contributionKeys.summaryPairs(), groupId, periodStart, periodEnd] as const,
/** Prefix match invalidates every cached period for one group */
summaryPairsForGroup: (groupId: string) =>
[...contributionKeys.summaryPairs(), groupId] as const,
userByIdentifier: (email: string) =>
[...contributionKeys.all, 'user-by-identifier', email] as const,
}
23 changes: 23 additions & 0 deletions src/features/admin/api/contributions/fetch-user-by-identifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/axios'
import type { User } from '@/types'
import { contributionKeys } from './contribution-keys'

const PROFILE_STALE_MS = 20 * 60 * 1000

export async function fetchUserByIdentifier(email: string): Promise<User> {
const date = new Date().toISOString()
return apiClient.get(
`/user/by-identifier/${encodeURIComponent(email)}?date=${encodeURIComponent(date)}`
)
}

export function useUserByIdentifier(email: string | undefined, enabled: boolean) {
return useQuery({
queryKey: contributionKeys.userByIdentifier(email ?? ''),
queryFn: () => fetchUserByIdentifier(email as string),
enabled: Boolean(email) && enabled,
staleTime: PROFILE_STALE_MS,
retry: 1,
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useQuery } from '@tanstack/react-query'
import { apiClient } from '@/lib/axios'
import type {
ContributionSummaryQueryParams,
GroupContributionSummaryResponse,
} from '@/types'
import { contributionKeys } from './contribution-keys'

const CONTRIBUTIONS_STALE_MS = 20 * 60 * 1000

export async function getGroupContributionSummary(
groupId: string,
params?: ContributionSummaryQueryParams
): Promise<GroupContributionSummaryResponse> {
const search = new URLSearchParams()
if (params) {
search.set('start_date', params.start_date)
search.set('end_date', params.end_date)
}
const qs = search.toString()
return apiClient.get(
`/contributions/${encodeURIComponent(groupId)}/summary${qs ? `?${qs}` : ''}`
)
}

export interface GroupContributionSummaryPair {
overall: GroupContributionSummaryResponse
filtered: GroupContributionSummaryResponse
}

export function useGroupContributionSummaryPair(options: {
groupId: string | undefined
period: { start: string; end: string }
enabled: boolean
}) {
const { groupId, period, enabled } = options

return useQuery({
queryKey: contributionKeys.summaryPair(
groupId ?? 'none',
period.start,
period.end
),
queryFn: async (): Promise<GroupContributionSummaryPair> => {
const id = groupId as string
const range: ContributionSummaryQueryParams = {
start_date: period.start,
end_date: period.end,
}
const [overall, filtered] = await Promise.all([
getGroupContributionSummary(id),
getGroupContributionSummary(id, range),
])
return { overall, filtered }
},
enabled: Boolean(groupId) && enabled,
staleTime: CONTRIBUTIONS_STALE_MS,
retry: 1,
})
}
10 changes: 10 additions & 0 deletions src/features/admin/api/contributions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export {
getGroupContributionSummary,
useGroupContributionSummaryPair,
} from './get-group-contribution-summary'
export type { GroupContributionSummaryPair } from './get-group-contribution-summary'
export { contributionKeys } from './contribution-keys'
export {
fetchUserByIdentifier,
useUserByIdentifier,
} from './fetch-user-by-identifier'
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'

interface AdminContributionsDateFilterProps {
/** Unique prefix for input ids when multiple filters exist on one page */
inputIdPrefix: string
draftStart: string
draftEnd: string
onDraftStartChange: (value: string) => void
onDraftEndChange: (value: string) => void
onApply: () => void
onClear: () => void
validationError: string | null
}

export function AdminContributionsDateFilter({
inputIdPrefix,
draftStart,
draftEnd,
onDraftStartChange,
onDraftEndChange,
onApply,
onClear,
validationError,
}: AdminContributionsDateFilterProps) {
const { t } = useTranslation('admin')
const startId = `${inputIdPrefix}-start`
const endId = `${inputIdPrefix}-end`

return (
<div className="space-y-3 rounded-lg border border-border bg-card p-4">
<p className="text-sm font-medium">{t('userContributions.dateFilterTitleGroup')}</p>
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end">
<div className="space-y-1.5">
<Label htmlFor={startId}>{t('userContributions.startDateInclusive')}</Label>
<Input
id={startId}
type="date"
value={draftStart}
onChange={(e) => onDraftStartChange(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={endId}>{t('userContributions.endDateInclusive')}</Label>
<Input
id={endId}
type="date"
value={draftEnd}
onChange={(e) => onDraftEndChange(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button type="button" onClick={onApply}>
{t('userContributions.apply')}
</Button>
<Button type="button" variant="outline" onClick={onClear}>
{t('userContributions.clear')}
</Button>
</div>
</div>
{validationError ? (
<p className="text-sm text-destructive">{validationError}</p>
) : null}
</div>
)
}
Loading