From a34b546eddfcf91050e4a605d035fce300c36944 Mon Sep 17 00:00:00 2001 From: Jampaa <106014359+Jampaa@users.noreply.github.com> Date: Thu, 14 May 2026 11:37:02 +0530 Subject: [PATCH] feat: add user contributions summary --- docs/api.md | 19 ++ src/components/layout/sidebar.tsx | 78 ++++---- .../api/contributions/contribution-keys.ts | 11 ++ .../contributions/fetch-user-by-identifier.ts | 23 +++ .../get-group-contribution-summary.ts | 60 ++++++ src/features/admin/api/contributions/index.ts | 10 + .../admin-contributions-date-filter.tsx | 68 +++++++ .../admin-group-contribution-row.tsx | 153 +++++++++++++++ .../contribution-summary-tables.tsx | 175 +++++++++++++++++ .../admin/components/contributions/index.ts | 4 + .../contributions/user-contributions-page.tsx | 177 ++++++++++++++++++ src/features/admin/components/index.ts | 1 + src/lib/contribution-date-range.ts | 35 ++++ src/locales/bo/admin.json | 41 ++++ src/locales/bo/common.json | 3 +- src/locales/en/admin.json | 43 ++++- src/locales/en/common.json | 6 +- .../admin/admin-user-contributions-page.tsx | 9 + src/routes/app-routes.tsx | 176 +++++++++-------- src/types/contributions.ts | 28 +++ src/types/index.ts | 8 + 21 files changed, 1008 insertions(+), 120 deletions(-) create mode 100644 src/features/admin/api/contributions/contribution-keys.ts create mode 100644 src/features/admin/api/contributions/fetch-user-by-identifier.ts create mode 100644 src/features/admin/api/contributions/get-group-contribution-summary.ts create mode 100644 src/features/admin/api/contributions/index.ts create mode 100644 src/features/admin/components/contributions/admin-contributions-date-filter.tsx create mode 100644 src/features/admin/components/contributions/admin-group-contribution-row.tsx create mode 100644 src/features/admin/components/contributions/contribution-summary-tables.tsx create mode 100644 src/features/admin/components/contributions/index.ts create mode 100644 src/features/admin/components/contributions/user-contributions-page.tsx create mode 100644 src/lib/contribution-date-range.ts create mode 100644 src/pages/admin/admin-user-contributions-page.tsx create mode 100644 src/types/contributions.ts diff --git a/docs/api.md b/docs/api.md index 1b8a536..bf1bba5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index aa752f8..93a6fc1 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -11,6 +11,7 @@ import { ChevronLeft, ChevronRight, Settings, + BarChart3, } from 'lucide-react' import { cn, getInitials, getRoleTranslationKey } from '@/lib/utils' import { useAuth } from '@/features/auth' @@ -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', @@ -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() { @@ -62,7 +76,6 @@ export function Sidebar() { const [settingsOpen, setSettingsOpen] = useState(false) const sidebarRef = useRef(null) - // Close settings when clicking outside sidebar useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( @@ -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 = () => { @@ -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 ( diff --git a/src/features/admin/api/contributions/contribution-keys.ts b/src/features/admin/api/contributions/contribution-keys.ts new file mode 100644 index 0000000..6526359 --- /dev/null +++ b/src/features/admin/api/contributions/contribution-keys.ts @@ -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, +} diff --git a/src/features/admin/api/contributions/fetch-user-by-identifier.ts b/src/features/admin/api/contributions/fetch-user-by-identifier.ts new file mode 100644 index 0000000..879eb8c --- /dev/null +++ b/src/features/admin/api/contributions/fetch-user-by-identifier.ts @@ -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 { + 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, + }) +} diff --git a/src/features/admin/api/contributions/get-group-contribution-summary.ts b/src/features/admin/api/contributions/get-group-contribution-summary.ts new file mode 100644 index 0000000..418704a --- /dev/null +++ b/src/features/admin/api/contributions/get-group-contribution-summary.ts @@ -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 { + 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 => { + 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, + }) +} diff --git a/src/features/admin/api/contributions/index.ts b/src/features/admin/api/contributions/index.ts new file mode 100644 index 0000000..186d006 --- /dev/null +++ b/src/features/admin/api/contributions/index.ts @@ -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' diff --git a/src/features/admin/components/contributions/admin-contributions-date-filter.tsx b/src/features/admin/components/contributions/admin-contributions-date-filter.tsx new file mode 100644 index 0000000..537b758 --- /dev/null +++ b/src/features/admin/components/contributions/admin-contributions-date-filter.tsx @@ -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 ( +
+

{t('userContributions.dateFilterTitleGroup')}

+
+
+ + onDraftStartChange(e.target.value)} + /> +
+
+ + onDraftEndChange(e.target.value)} + /> +
+
+ + +
+
+ {validationError ? ( +

{validationError}

+ ) : null} +
+ ) +} diff --git a/src/features/admin/components/contributions/admin-group-contribution-row.tsx b/src/features/admin/components/contributions/admin-group-contribution-row.tsx new file mode 100644 index 0000000..21e4556 --- /dev/null +++ b/src/features/admin/components/contributions/admin-group-contribution-row.tsx @@ -0,0 +1,153 @@ +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { isAxiosError } from 'axios' +import { useQueryClient } from '@tanstack/react-query' +import { ChevronDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { cn } from '@/lib/utils' +import { + contributionKeys, + useGroupContributionSummaryPair, +} from '@/features/admin/api/contributions' +import { + CONTRIBUTION_DEFAULT_FILTERED_DAYS, + getRollingInclusiveDaysRange, +} from '@/lib/contribution-date-range' +import { AdminContributionsDateFilter } from './admin-contributions-date-filter' +import { ContributionSummaryTables } from './contribution-summary-tables' + +interface AdminGroupContributionRowProps { + groupId: string + groupName: string +} + +function contributionUnavailableMessage( + error: unknown, + t: (key: string) => string +): string { + const status = isAxiosError(error) ? error.response?.status : undefined + if (status === 404) return t('userContributions.groupNotFound') + if (status === 400) return t('userContributions.invalidDateRange') + return t('userContributions.featureNotImplementedForGroup') +} + +export function AdminGroupContributionRow({ groupId, groupName }: AdminGroupContributionRowProps) { + const { t } = useTranslation('admin') + const queryClient = useQueryClient() + const [open, setOpen] = useState(false) + + const defaultPeriod = useMemo( + () => getRollingInclusiveDaysRange(CONTRIBUTION_DEFAULT_FILTERED_DAYS), + [] + ) + const [appliedPeriod, setAppliedPeriod] = useState(defaultPeriod) + const [draftStart, setDraftStart] = useState(defaultPeriod.start) + const [draftEnd, setDraftEnd] = useState(defaultPeriod.end) + const [validationError, setValidationError] = useState(null) + + const invalidateThisGroup = useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: contributionKeys.summaryPairsForGroup(groupId), + }) + }, [groupId, queryClient]) + + const handleApply = useCallback(() => { + if (!draftStart || !draftEnd) { + setValidationError(t('userContributions.validationDatesRequired')) + return + } + if (draftStart > draftEnd) { + setValidationError(t('userContributions.validationStartBeforeEnd')) + return + } + setValidationError(null) + setAppliedPeriod({ start: draftStart, end: draftEnd }) + invalidateThisGroup() + }, [draftEnd, draftStart, invalidateThisGroup, t]) + + const handleClear = useCallback(() => { + const next = getRollingInclusiveDaysRange(CONTRIBUTION_DEFAULT_FILTERED_DAYS) + setAppliedPeriod(next) + setDraftStart(next.start) + setDraftEnd(next.end) + setValidationError(null) + invalidateThisGroup() + }, [invalidateThisGroup]) + + const { data, isLoading, isFetching, error, refetch } = useGroupContributionSummaryPair({ + groupId, + period: appliedPeriod, + enabled: open, + }) + + const httpStatus = isAxiosError(error) ? error.response?.status : undefined + + const inputIdPrefix = useMemo(() => `contrib-group-${groupId.replace(/[^a-zA-Z0-9-]/g, '')}`, [groupId]) + + const showLoading = isLoading || (isFetching && !data) + const showDateFilter = + Boolean(data) || (Boolean(error) && httpStatus === 400) + + const dateFilterBlock = showDateFilter ? ( + + ) : null + + return ( +
+ + + {open ? ( +
+ {showLoading ? ( +
+ + +
+ ) : error && httpStatus !== 400 ? ( +
+

+ {contributionUnavailableMessage(error, t)} +

+ +
+ ) : error && httpStatus === 400 ? ( +
+ {dateFilterBlock} +
+

{contributionUnavailableMessage(error, t)}

+
+
+ ) : data ? ( + <> + {dateFilterBlock} + + + ) : null} +
+ ) : null} +
+ ) +} diff --git a/src/features/admin/components/contributions/contribution-summary-tables.tsx b/src/features/admin/components/contributions/contribution-summary-tables.tsx new file mode 100644 index 0000000..45f32d5 --- /dev/null +++ b/src/features/admin/components/contributions/contribution-summary-tables.tsx @@ -0,0 +1,175 @@ +import { useTranslation } from 'react-i18next' +import type { + AnnotatorContributionRow, + GroupContributionSummaryResponse, + ReviewerContributionRow, +} from '@/types' + +function MetricPair({ overall, filtered }: { overall: number; filtered: number }) { + return ( + + {overall}{' '} + ( + {filtered} + ) + + ) +} + +interface ContributionSummaryTablesProps { + overall: GroupContributionSummaryResponse + filtered: GroupContributionSummaryResponse + /** When set, shows a hint that parenthetical values cover this many days (non-admin). */ + filteredWindowDaysForHint?: number +} + +export function ContributionSummaryTables({ + overall, + filtered, + filteredWindowDaysForHint, +}: ContributionSummaryTablesProps) { + const { t } = useTranslation('admin') + + const annotOverallById = new Map( + overall.annotator.map((r) => [r.user_id, r] as const) + ) + const revOverallById = new Map( + overall.reviewer.map((r) => [r.user_id, r] as const) + ) + + return ( +
+ {filteredWindowDaysForHint != null ? ( +

+ {t('userContributions.nonAdminFilteredHint', { count: filteredWindowDaysForHint })} +

+ ) : null} + +
+

{t('userContributions.annotatorsSection')}

+ {filtered.annotator.length === 0 ? ( +

+ {t('userContributions.noAnnotators')} +

+ ) : ( +
+ + + + + + + + + + + {filtered.annotator.map((row) => ( + + ))} + +
+ {t('userContributions.tables.annotator.username')} + + {t('userContributions.tables.annotator.totalAnnotated')} + + {t('userContributions.tables.annotator.reviewedCount')} + + {t('userContributions.tables.annotator.approvedCount')} +
+
+ )} +
+ +
+

{t('userContributions.reviewersSection')}

+ {filtered.reviewer.length === 0 ? ( +

+ {t('userContributions.noReviewers')} +

+ ) : ( +
+ + + + + + + + + + + {filtered.reviewer.map((row) => ( + + ))} + +
+ {t('userContributions.tables.reviewer.username')} + + {t('userContributions.tables.reviewer.totalReviewed')} + + {t('userContributions.tables.reviewer.verifiedCount')} + + {t('userContributions.tables.reviewer.totalRejection')} +
+
+ )} +
+
+ ) +} + +function AnnotatorRow({ + filtered, + overall, +}: { + filtered: AnnotatorContributionRow + overall: AnnotatorContributionRow | undefined +}) { + const o = overall ?? filtered + return ( + + {filtered.username} + + + + + + + + + + + ) +} + +function ReviewerRow({ + filtered, + overall, +}: { + filtered: ReviewerContributionRow + overall: ReviewerContributionRow | undefined +}) { + const o = overall ?? filtered + return ( + + {filtered.username} + + + + + + + + + + + ) +} diff --git a/src/features/admin/components/contributions/index.ts b/src/features/admin/components/contributions/index.ts new file mode 100644 index 0000000..ad7cca4 --- /dev/null +++ b/src/features/admin/components/contributions/index.ts @@ -0,0 +1,4 @@ +export { UserContributionsPage } from './user-contributions-page' +export { ContributionSummaryTables } from './contribution-summary-tables' +export { AdminContributionsDateFilter } from './admin-contributions-date-filter' +export { AdminGroupContributionRow } from './admin-group-contribution-row' diff --git a/src/features/admin/components/contributions/user-contributions-page.tsx b/src/features/admin/components/contributions/user-contributions-page.tsx new file mode 100644 index 0000000..83280ef --- /dev/null +++ b/src/features/admin/components/contributions/user-contributions-page.tsx @@ -0,0 +1,177 @@ +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { isAxiosError } from 'axios' +import { useQueryClient } from '@tanstack/react-query' +import { useAuth } from '@/features/auth' +import { useGetGroups } from '@/features/admin/api/group' +import { + contributionKeys, + useGroupContributionSummaryPair, + useUserByIdentifier, +} from '@/features/admin/api/contributions' +import { + CONTRIBUTION_DEFAULT_FILTERED_DAYS, + getRollingInclusiveDaysRange, +} from '@/lib/contribution-date-range' +import { UserRole } from '@/types' +import { LoadingSpinner } from '@/components/common' +import { Button } from '@/components/ui/button' +import { AdminGroupContributionRow } from './admin-group-contribution-row' +import { ContributionSummaryTables } from './contribution-summary-tables' + +function hasGroupId(value: string | undefined | null): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +function summaryErrorMessage(error: unknown, t: (key: string) => string): string { + const status = isAxiosError(error) ? error.response?.status : undefined + if (status === 400) return t('userContributions.invalidDateRange') + return t('userContributions.featureNotImplementedForGroup') +} + +export function UserContributionsPage() { + const { currentUser } = useAuth() + const { t } = useTranslation('admin') + + const isAdmin = currentUser?.role === UserRole.Admin + + if (!currentUser?.role) { + return null + } + + return ( +
+
+

{t('userContributions.title')}

+

{t('userContributions.description')}

+
+ + {isAdmin ? : } +
+ ) +} + +function NonAdminContributionsBody() { + const { t } = useTranslation('admin') + const { currentUser } = useAuth() + const email = currentUser?.email + const queryClient = useQueryClient() + + const profileQuery = useUserByIdentifier(email, Boolean(email)) + + const defaultPeriod = useMemo( + () => getRollingInclusiveDaysRange(CONTRIBUTION_DEFAULT_FILTERED_DAYS), + [] + ) + + const groupId = profileQuery.data?.group_id + const groupReady = hasGroupId(groupId) + + const summaryQuery = useGroupContributionSummaryPair({ + groupId: groupReady ? groupId : undefined, + period: defaultPeriod, + enabled: groupReady && profileQuery.isSuccess, + }) + + const showFullPageSpinner = + profileQuery.isLoading || (groupReady && summaryQuery.isLoading && !summaryQuery.data) + + const handleRefresh = useCallback(() => { + void queryClient.invalidateQueries({ + queryKey: contributionKeys.userByIdentifier(email ?? 'none'), + }) + void queryClient.invalidateQueries({ + queryKey: contributionKeys.summaryPairs(), + }) + }, [email, queryClient]) + + if (showFullPageSpinner) { + return ( +
+ +
+ ) + } + + if (profileQuery.isError) { + return ( +
+

{t('userContributions.featureNotImplementedForGroup')}

+ +
+ ) + } + + if (!groupReady) { + return ( +
+

{t('userContributions.noGroupYet')}

+ +
+ ) + } + + if (summaryQuery.isError || !summaryQuery.data) { + return ( +
+

+ {summaryQuery.error ? summaryErrorMessage(summaryQuery.error, t) : t('userContributions.featureNotImplementedForGroup')} +

+ +
+ ) + } + + return ( +
+

+ {summaryQuery.data.overall.group_name} +

+ +
+ ) +} + +function AdminContributionsBody() { + const { t } = useTranslation('admin') + const { data: groups = [], isLoading: groupsLoading, isError: groupsError } = useGetGroups() + + if (groupsLoading) { + return ( +
+ +
+ ) + } + + if (groupsError) { + return ( +

{t('userContributions.groupsLoadError')}

+ ) + } + + if (groups.length === 0) { + return ( +

{t('userContributions.noGroups')}

+ ) + } + + return ( +
+

{t('userContributions.adminExpandHint')}

+ {groups.map((g) => ( + + ))} +
+ ) +} diff --git a/src/features/admin/components/index.ts b/src/features/admin/components/index.ts index ffb2bef..5ba34ea 100644 --- a/src/features/admin/components/index.ts +++ b/src/features/admin/components/index.ts @@ -1,4 +1,5 @@ export * from './user' export * from './group' export * from './batch' +export * from './contributions' diff --git a/src/lib/contribution-date-range.ts b/src/lib/contribution-date-range.ts new file mode 100644 index 0000000..9fe07f7 --- /dev/null +++ b/src/lib/contribution-date-range.ts @@ -0,0 +1,35 @@ +/** YYYY-MM-DD in local calendar */ +function formatYmd(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +/** Default filtered window for contribution summaries (rolling, inclusive both ends). */ +export const CONTRIBUTION_DEFAULT_FILTERED_DAYS = 30 + +/** + * Rolling window ending on `reference`'s calendar date: `days` distinct calendar days, + * both **start** and **end** inclusive (matches batch API: start_date and end_date inclusive). + * Example: days=30 → end is today, start is 29 calendar days earlier (30 days total). + */ +export function getRollingInclusiveDaysRange( + days: number, + reference = new Date() +): { start: string; end: string } { + const end = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate()) + const start = new Date(end) + start.setDate(start.getDate() - (days - 1)) + return { start: formatYmd(start), end: formatYmd(end) } +} + +/** First day of current month → first day of next month (legacy month UI; end was exclusive-API style). Prefer {@link getRollingInclusiveDaysRange} for contributions. */ +export function getDefaultCalendarMonthRange(reference = new Date()): { + start: string + end: string +} { + const start = new Date(reference.getFullYear(), reference.getMonth(), 1) + const end = new Date(reference.getFullYear(), reference.getMonth() + 1, 1) + return { start: formatYmd(start), end: formatYmd(end) } +} diff --git a/src/locales/bo/admin.json b/src/locales/bo/admin.json index 6f64abc..85fc243 100644 --- a/src/locales/bo/admin.json +++ b/src/locales/bo/admin.json @@ -171,6 +171,47 @@ "failedDescription": "ཚོ་སྒྲིག་འགེལ་མ་ཐུབ། ཡང་བསྐྱར་ཚོད་ལྟ།" } }, + "userContributions": { + "title": "སྤྱོད་མཁན་གྱི་བྱས་རྗེས།", + "description": "ཁྱེད་ཀྱི་སྡེ་ཚན་ཡང་ན་སྡེ་ཚན་ཡོངས་ཀྱི་ཡིག་འབྲི་དང་བསྐྱར་ཞིབ་པའི་བྱས་རྗེས་བསྡོམས་རྩིས་ལྟ།", + "noGroupYet": "ཁྱེད་ད་དུང་སྡེ་ཚན་གང་ལའང་མི་སྒྲིག", + "refresh": "གསར་སྒྱུར།", + "profileLoadError": "ཁྱེད་ཀྱི་ཐོ་གཞུང་སྣོན་མ་ཐུབ། ཡང་བསྐྱར་ཚོད་ལྟ།", + "summaryLoadError": "བྱས་རྗེས་སྤྱི་བསྡོམས་སྣོན་མ་ཐུབ། ཡང་བསྐྱར་ཚོད་ལྟ།", + "groupsLoadError": "སྡེ་ཚན་སྣོན་མ་ཐུབ། ཡང་བསྐྱར་ཚོད་ལྟ།", + "noGroups": "སྡེ་ཚན་མེད།", + "groupNotFound": "སྡེ་ཚན་འདི་མ་རྙེད།", + "failedToLoadGroup": "སྡེ་ཚན་འདིའི་བྱས་རྗེས་གྲངས་གཞི་སྣོན་མ་ཐུབ།", + "featureNotImplementedForGroup": "ཁྱེད་རང་གི་ཚོགས་པའི་ཆེད་དུ་ཁྱད་ཆོས་འདི་ད་ལྟ་བཟོས་མེད།", + "invalidDateRange": "ཚེས་གྲངས་ནོར་འཁྲུལ། YYYY-MM-DD སྤྱོད། འགོ་དང་མཇུག་ཚེས་གཉིས་ཆ་ཚང་ཚུད་དགོས།", + "dateFilterTitleGroup": "སྡེ་ཚན་འདིའི་ཚེས་གྲངས་ཁྱབ་ཁོངས། (ཚགས་མའི་བསྡོམས་རྩིས།)", + "startDateInclusive": "འགོ་ཚེས། (ཚུད་པ།)", + "endDateInclusive": "མཇུག་ཚེས། (ཚུད་པ།)", + "apply": "སྤྱོད།", + "clear": "གཙང་བཟོ།", + "validationDatesRequired": "འགོ་དང་མཇུག་ཚེས་གཉིས་བཀོད།", + "validationStartBeforeEnd": "འགོ་ཚེས་མཇུག་ཚེས་ལས་སྔ་རམ་མཉམ་ཡིན་དགོས། (གཉིས་ཆ་ཚང་ཚུད་པ།)", + "adminExpandHint": "སྡེ་ཚན་ཁ་བཀེག་ནས་ཚེས་གྲངས་ཁྱབ་ཁོངས་སྒྲིག་དང་བྱས་རྗེས་བསྡོམས་རྩིས་ལྟ། སྡེ་ཚན་རེ་རེར་ཚགས་མ་རང་བཞིན་ཡོད།", + "nonAdminFilteredHint": "ཁ་བཀག་གྲངས་ནི་ཉིན་ {{count}} ཕྱི་མའི་བསྡོམས་རྩིས་ཡིན། (འགོ་དང་མཇུག་ཚེས་གཉིས་ཆ་ཚང་ཚུད་པ།)", + "annotatorsSection": "ཡིག་འབྲི་བ།", + "reviewersSection": "བསྐྱར་ཞིབ་པ།", + "noAnnotators": "དུས་ཡུན་འདིར་ཡིག་འབྲི་གྲངས་གཞི་མེད།", + "noReviewers": "དུས་ཡུན་འདིར་བསྐྱར་ཞིབ་གྲངས་གཞི་མེད།", + "tables": { + "annotator": { + "username": "སྤྱོད་མཁན་མིང་།", + "totalAnnotated": "ཡིག་འབྲི་བསྡོམས།", + "reviewedCount": "བསྐྱར་ཞིབ་གྲངས།", + "approvedCount": "ཆོག་མཆན་གྲངས།" + }, + "reviewer": { + "username": "སྤྱོད་མཁན་མིང་།", + "totalReviewed": "བསྐྱར་ཞིབ་བསྡོམས།", + "verifiedCount": "ངེས་འཁེལ་གྲངས།", + "totalRejection": "དགག་པ་བསྡོམས།" + } + } + }, "common": { "cancel": "དོར།", "save": "ཉར།", diff --git a/src/locales/bo/common.json b/src/locales/bo/common.json index 080b330..8116a6e 100644 --- a/src/locales/bo/common.json +++ b/src/locales/bo/common.json @@ -3,7 +3,8 @@ "dashboard": "མདུན་ངོས།", "users": "སྤྱོད་མཁན།", "groups": "སྡེ་ཚན།", - "batches": "ཚོ་སྒྲིག" + "batches": "ཚོ་སྒྲིག", + "userContributions": "སྤྱོད་མཁན་བྱས་རྗེས།" }, "actions": { "save": "ཉར་བ།", diff --git a/src/locales/en/admin.json b/src/locales/en/admin.json index 041967f..5ac29f9 100644 --- a/src/locales/en/admin.json +++ b/src/locales/en/admin.json @@ -173,6 +173,47 @@ "failedDescription": "Failed to upload batch. Please try again." } }, + "userContributions": { + "title": "User Contributions", + "description": "View annotator and reviewer contribution totals for your group.", + "noGroupYet": "You don't belong to any group yet.", + "refresh": "Refresh", + "profileLoadError": "Could not load your profile. Please try again.", + "summaryLoadError": "Could not load contribution summary. Please try again.", + "groupsLoadError": "Could not load groups. Please try again.", + "noGroups": "No groups are available.", + "groupNotFound": "This group was not found.", + "failedToLoadGroup": "Could not load contribution data for this group.", + "featureNotImplementedForGroup": "This feature is not implemented for your group yet.", + "invalidDateRange": "Invalid dates. Use YYYY-MM-DD. Start must be on or before end (both days inclusive).", + "dateFilterTitleGroup": "Date range for this group (filtered totals)", + "startDateInclusive": "Start date (inclusive)", + "endDateInclusive": "End date (inclusive)", + "apply": "Apply", + "clear": "Clear", + "validationDatesRequired": "Enter both start and end dates.", + "validationStartBeforeEnd": "Start date must be on or before end date (both inclusive).", + "adminExpandHint": "Expand a group to set its date range and view contribution totals. Each group uses its own filter.", + "nonAdminFilteredHint": "(Numbers in parentheses are totals for the last {{count}} days.)", + "annotatorsSection": "Annotators", + "reviewersSection": "Reviewers", + "noAnnotators": "No annotator data for this period.", + "noReviewers": "No reviewer data for this period.", + "tables": { + "annotator": { + "username": "Username", + "totalAnnotated": "Total annotated", + "reviewedCount": "Reviewed count", + "approvedCount": "Approved count" + }, + "reviewer": { + "username": "Username", + "totalReviewed": "Total reviewed", + "verifiedCount": "Verified count", + "totalRejection": "Total rejection" + } + } + }, "common": { "cancel": "Cancel", "save": "Save", @@ -180,4 +221,4 @@ "edit": "Edit", "loading": "Loading..." } -} +} \ No newline at end of file diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 7808df7..09aef0b 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -3,7 +3,8 @@ "dashboard": "Dashboard", "users": "Users", "groups": "Groups", - "batches": "Batches" + "batches": "Batches", + "userContributions": "Contributions" }, "actions": { "save": "Save", @@ -65,5 +66,4 @@ "appName": "Script Classification", "tagline": "Script Classification Tool" } -} - +} \ No newline at end of file diff --git a/src/pages/admin/admin-user-contributions-page.tsx b/src/pages/admin/admin-user-contributions-page.tsx new file mode 100644 index 0000000..7858524 --- /dev/null +++ b/src/pages/admin/admin-user-contributions-page.tsx @@ -0,0 +1,9 @@ +import { UserContributionsPage } from '@/features/admin/components/contributions' + +export function AdminUserContributionsPage() { + return ( +
+ +
+ ) +} diff --git a/src/routes/app-routes.tsx b/src/routes/app-routes.tsx index 1468aa8..1bcd282 100644 --- a/src/routes/app-routes.tsx +++ b/src/routes/app-routes.tsx @@ -12,6 +12,9 @@ const DashboardPage = lazy(() => import('@/pages/dashboard/dashboard-page').then const AdminUsersPage = lazy(() => import('@/pages/admin/admin-users-page').then(m => ({ default: m.AdminUsersPage }))) const AdminGroupsPage = lazy(() => import('@/pages/admin/admin-groups-page').then(m => ({ default: m.AdminGroupsPage }))) const AdminBatchesPage = lazy(() => import('@/pages/admin/admin-batches-page').then(m => ({ default: m.AdminBatchesPage }))) +const AdminUserContributionsPage = lazy(() => + import('@/pages/admin/admin-user-contributions-page').then(m => ({ default: m.AdminUserContributionsPage })) +) const AdminBatchTasksPage = lazy(() => import('@/pages/admin/admin-batch-tasks-page').then(m => ({ default: m.AdminBatchTasksPage }))) const WorkspacePage = lazy(() => import('@/pages/workspace/workspace-page').then(m => ({ default: m.WorkspacePage }))) const NotFoundPage = lazy(() => import('@/pages/not-found').then(m => ({ default: m.NotFoundPage }))) @@ -25,97 +28,116 @@ export const router = createBrowserRouter([ errorElement: , children: [ // Auth routes (public) - { - element: , - children: [ - { - path: '/login', - element: , - }, - { - path: '/callback', - element: , - }, - { - path: '/pending-approval', - element: , - }, - ], - }, - // Protected routes - { - element: ( - - - - ), - children: [ - { - path: '/', - element: , - }, { - path: '/dashboard', - element: , - }, - { - path: '/admin/users', - element: ( - - - - ) + element: , + children: [ + { + path: '/login', + element: , + }, + { + path: '/callback', + element: , + }, + { + path: '/pending-approval', + element: , + }, + ], }, + // Protected routes { - path: '/admin/groups', element: ( - - - - ), - }, - { - path: '/admin/batches', - element: ( - - + + ), + children: [ + { + path: '/', + element: , + }, + { + path: '/dashboard', + element: , + }, + { + path: '/admin/users', + element: ( + + + + ) + }, + { + path: '/admin/groups', + element: ( + + + + ), + }, + { + path: '/admin/batches', + element: ( + + + + ), + }, + { + path: '/admin/batches/user-contributions', + element: , + }, + { + path: '/admin/user-contributions', + element: ( + + + + ), + }, + { + path: '/admin/batch/:batchId', + element: ( + + + + ), + }, + { + path: '/settings', + element: ( +
+

Settings page coming soon...

+
+ ), + }, + ], }, + + // Workspace route (has its own layout) { - path: '/admin/batch/:batchId', + path: '/workspace', element: ( - - + + ), }, + + // 404 { - path: '/settings', - element: ( -
-

Settings page coming soon...

-
- ), + path: '*', + element: , }, ], }, - - // Workspace route (has its own layout) - { - path: '/workspace', - element: ( - - - - ), - }, - - // 404 - { - path: '*', - element: , - }, - ], - }, ]) diff --git a/src/types/contributions.ts b/src/types/contributions.ts new file mode 100644 index 0000000..57bc9ef --- /dev/null +++ b/src/types/contributions.ts @@ -0,0 +1,28 @@ +/** Query params for GET /contributions/{group_id}/summary */ +export interface ContributionSummaryQueryParams { + start_date: string + end_date: string +} + +export interface AnnotatorContributionRow { + user_id: string + username: string + total_annotated: number + reviewed_count: number + approved_count: number +} + +export interface ReviewerContributionRow { + user_id: string + username: string + total_reviewed: number + verified_count: number + total_rejection: number +} + +export interface GroupContributionSummaryResponse { + group_id: string + group_name: string + annotator: AnnotatorContributionRow[] + reviewer: ReviewerContributionRow[] +} diff --git a/src/types/index.ts b/src/types/index.ts index 2acd0f7..0323ef5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -60,3 +60,11 @@ export type { // API types export type { ApiResponse } from './api' + +// Contributions +export type { + ContributionSummaryQueryParams, + AnnotatorContributionRow, + ReviewerContributionRow, + GroupContributionSummaryResponse, +} from './contributions'