Skip to content
Merged
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
25 changes: 24 additions & 1 deletion client/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"html5-qrcode": "^2.3.8",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export default function AllApplicantsPage() {
<div className="space-y-6">
<SectionCards stats={stats} loading={statsLoading} />

<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<StatusFilterTabs
stats={stats}
loading={loading}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface SectionCardsProps {
export function SectionCards({ stats, loading }: SectionCardsProps) {
if (loading) {
return (
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i} className="@container/card animate-pulse">
<CardHeader>
Expand Down Expand Up @@ -57,18 +57,22 @@ export function SectionCards({ stats, loading }: SectionCardsProps) {
];

return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-4 gap-4 *:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:shadow-xs">
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-2 gap-4 *:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:shadow-xs lg:grid-cols-4">
{cards.map((card) => (
<Card key={card.title} className="@container/card">
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{card.title}</CardDescription>
<card.icon className="size-5 text-muted-foreground" />
<Card key={card.title} className="@container/card min-w-0">
<CardHeader className="min-w-0">
<div className="flex items-center justify-between gap-2">
<CardDescription className="truncate">
{card.title}
</CardDescription>
<card.icon className="size-5 shrink-0 text-muted-foreground" />
</div>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
<CardTitle className="truncate text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{card.value}
</CardTitle>
<p className="text-sm text-muted-foreground">{card.description}</p>
<p className="truncate text-sm text-muted-foreground">
{card.description}
</p>
</CardHeader>
</Card>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ export function StatusFilterTabs({
<Tabs
value={currentStatus ?? "all"}
onValueChange={handleValueChange}
className="w-auto"
className="min-w-0"
>
<TabsList>
<TabsList className="h-auto flex-wrap justify-start gap-1 p-1 lg:h-9 lg:flex-nowrap lg:gap-0 lg:p-0.5">
<TabsTrigger
value="all"
disabled={loading}
Expand Down
40 changes: 39 additions & 1 deletion client/web/src/pages/admin/scans/ScansPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,41 @@
import { useEffect } from "react";

import { ScannerDialog } from "./components/ScannerDialog";
import { ScanStatsCards } from "./components/ScanStatsCards";
import { ScanTypeGrid } from "./components/ScanTypeGrid";
import { useScansStore } from "./store";

export default function ScansPage() {
return <div>Scans</div>;
const {
scanTypes,
stats,
typesLoading,
statsLoading,
fetchTypes,
fetchStats,
setActiveScanType,
} = useScansStore();

useEffect(() => {
const controller = new AbortController();
fetchTypes(controller.signal);
fetchStats(controller.signal);
return () => {
controller.abort();
// Reset active scan type so dialog doesn't reopen on navigate back
setActiveScanType(null);
};
}, [fetchTypes, fetchStats, setActiveScanType]);

return (
<div className="space-y-6">
<ScanStatsCards
scanTypes={scanTypes}
stats={stats}
loading={typesLoading || statsLoading}
/>
<ScanTypeGrid scanTypes={scanTypes} onSelect={setActiveScanType} />
<ScannerDialog />
</div>
);
}
37 changes: 37 additions & 0 deletions client/web/src/pages/admin/scans/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { getRequest, postRequest } from "@/shared/lib/api";
import type { ApiResponse } from "@/types";

import type { Scan, ScanStatsResponse, ScanTypesResponse } from "./types";

export async function fetchScanTypes(
signal?: AbortSignal,
): Promise<ApiResponse<ScanTypesResponse>> {
return getRequest<ScanTypesResponse>(
"/admin/scans/types",
"scan types",
signal,
);
}

export async function createScan(
userId: string,
scanType: string,
signal?: AbortSignal,
): Promise<ApiResponse<Scan>> {
return postRequest<Scan>(
"/admin/scans",
{ user_id: userId, scan_type: scanType },
"scan",
signal,
);
}

export async function fetchScanStats(
signal?: AbortSignal,
): Promise<ApiResponse<ScanStatsResponse>> {
return getRequest<ScanStatsResponse>(
"/admin/scans/stats",
"scan stats",
signal,
);
}
76 changes: 76 additions & 0 deletions client/web/src/pages/admin/scans/components/ScanStatsCards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Gift, MoreHorizontal, UserCheck, Utensils } from "lucide-react";

import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";

import type { ScanStat, ScanType, ScanTypeCategory } from "../types";

const categoryIcons: Record<ScanTypeCategory, typeof UserCheck> = {
check_in: UserCheck,
meal: Utensils,
swag: Gift,
other: MoreHorizontal,
};

interface ScanStatsCardsProps {
scanTypes: ScanType[];
stats: ScanStat[];
loading: boolean;
}

export function ScanStatsCards({
scanTypes,
stats,
loading,
}: ScanStatsCardsProps) {
if (loading) {
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
{[...Array(3)].map((_, i) => (
<Card key={i} className="animate-pulse">
<CardHeader>
<div className="h-4 w-24 rounded bg-gray-200" />
<div className="mt-2 h-8 w-16 rounded bg-gray-200" />
</CardHeader>
</Card>
))}
</div>
);
}

if (scanTypes.length === 0) {
return null;
}

const statsMap = new Map(stats.map((s) => [s.scan_type, s.count]));

return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-2 gap-4 *:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:shadow-xs md:grid-cols-3 lg:grid-cols-4">
{scanTypes.map((scanType) => {
const Icon = categoryIcons[scanType.category] ?? UserCheck;
const count = statsMap.get(scanType.name) ?? 0;

return (
<Card key={scanType.name} className="@container/card">
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription>{scanType.display_name}</CardDescription>
<Icon className="size-5 text-muted-foreground" />
</div>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{count}
</CardTitle>
<p className="text-sm capitalize text-muted-foreground">
{scanType.category.replace("_", " ")}
</p>
</CardHeader>
</Card>
);
})}
</div>
);
}
Loading