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
4 changes: 2 additions & 2 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ Runs on every push/PR to `main` (`.github/workflows/audit.yaml`):
## API Routes

**Auth:** `GET /v1/auth/check-email`, `GET /v1/auth/me`
**Hacker:** `GET|PATCH /v1/applications/me`, `POST /v1/applications/me/submit`
**Hacker:** `GET|PATCH /v1/applications/me`, `POST /v1/applications/me/submit`, `GET /v1/points-name`
**Admin:** `GET /v1/admin/applications`, `GET /v1/admin/applications/stats`, `GET /v1/admin/applications/{id}`, `GET /v1/admin/applications/{id}/notes`, `GET /v1/admin/reviews/pending`, `GET /v1/admin/reviews/completed`, `GET /v1/admin/reviews/next`, `PUT /v1/admin/reviews/{id}`, `GET /v1/admin/scans/types`, `POST /v1/admin/scans`, `GET /v1/admin/scans/user/{userID}`, `GET /v1/admin/scans/stats`, `POST /v1/admin/scans/rebalance-stats`
**Super Admin:** `GET|PUT /v1/superadmin/settings/saquestions`, `GET|POST /v1/superadmin/settings/reviews-per-app`, `GET|POST /v1/superadmin/settings/review-assignment-toggle`, `GET|POST /v1/superadmin/settings/admin-schedule-edit-toggle`, `POST /v1/superadmin/applications/assign`, `PATCH /v1/superadmin/applications/{id}/status`, `GET /v1/superadmin/applications/emails`, `PUT /v1/superadmin/settings/scan-types`, `POST /v1/superadmin/scans/rebalance-stats`, `POST /v1/superadmin/emails/qr`, `GET /v1/superadmin/walk-ins`, `POST /v1/superadmin/walk-ins/promote`
**Super Admin:** `GET|PUT /v1/superadmin/settings/saquestions`, `GET|POST /v1/superadmin/settings/reviews-per-app`, `GET|POST /v1/superadmin/settings/review-assignment-toggle`, `GET|POST /v1/superadmin/settings/admin-schedule-edit-toggle`, `POST /v1/superadmin/applications/assign`, `PATCH /v1/superadmin/applications/{id}/status`, `GET /v1/superadmin/applications/emails`, `PUT /v1/superadmin/settings/scan-types`, `POST /v1/superadmin/settings/points-name`, `POST /v1/superadmin/scans/rebalance-stats`, `POST /v1/superadmin/emails/qr`, `GET /v1/superadmin/walk-ins`, `POST /v1/superadmin/walk-ins/promote`
**Infra (Basic Auth):** `GET /v1/health`, `GET /v1/debug/vars`, `GET /v1/swagger/*`
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { SearchBar } from "@/pages/admin/_shared";
import { usePointsNameStore } from "@/shared/stores";

import { ApplicationDetailPanel } from "./components/ApplicationDetailPanel";
import { ApplicationsTable } from "./components/ApplicationsTable";
Expand All @@ -31,6 +32,7 @@ export default function AllApplicantsPage() {
const statsLoading = useApplicationsStore((s) => s.statsLoading);
const fetchApplications = useApplicationsStore((s) => s.fetchApplications);
const fetchStats = useApplicationsStore((s) => s.fetchStats);
const fetchPointsName = usePointsNameStore((s) => s.fetchPointsName);

const [searchInput, setSearchInput] = useState(currentSearch);
const [selectedApplicationId, setSelectedApplicationId] = useState<
Expand All @@ -46,8 +48,9 @@ export default function AllApplicantsPage() {
const controller = new AbortController();
fetchApplications(undefined, controller.signal);
fetchStats(controller.signal);
fetchPointsName(controller.signal);
return () => controller.abort();
}, [fetchApplications, fetchStats]);
}, [fetchApplications, fetchStats, fetchPointsName]);

const isFirstRender = useRef(true);
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { errorAlert } from "@/shared/lib/api";
import { usePointsNameStore } from "@/shared/stores";
import type { Application } from "@/types";

import { fetchApplicationResumeURL } from "../api";
Expand All @@ -34,6 +35,7 @@ export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({
onGrade,
}: ApplicationDetailPanelProps) {
const [isOpeningResume, setIsOpeningResume] = useState(false);
const pointsName = usePointsNameStore((s) => s.pointsName);

const handleViewResume = useCallback(async () => {
if (!application || !application.resume_path || isOpeningResume) {
Expand Down Expand Up @@ -76,6 +78,9 @@ export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({
<Badge className={getStatusColor(application.status)}>
{application.status}
</Badge>
<Badge variant="secondary" className="tabular-nums">
{application.points ?? 0} {pointsName}
</Badge>
</>
) : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { usePointsNameStore } from "@/shared/stores";

import type { ApplicationListItem } from "../types";
import { formatName, getStatusColor } from "../utils";
Expand All @@ -28,6 +29,8 @@ export const ApplicationsTable = memo(function ApplicationsTable({
selectedId,
onSelectApplication,
}: ApplicationsTableProps) {
const pointsName = usePointsNameStore((s) => s.pointsName);

return (
<div className="relative overflow-auto h-full p-6 pt-0">
{loading && (
Expand All @@ -51,12 +54,13 @@ export const ApplicationsTable = memo(function ApplicationsTable({
<TableHead>Created</TableHead>
<TableHead>Updated</TableHead>
<TableHead>AI Percent</TableHead>
<TableHead>{pointsName}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{applications.length === 0 ? (
<TableRow>
<TableCell colSpan={14} className="text-center text-gray-500">
<TableCell colSpan={16} className="text-center text-gray-500">
No applications found
</TableCell>
</TableRow>
Expand Down Expand Up @@ -107,6 +111,7 @@ export const ApplicationsTable = memo(function ApplicationsTable({
<TableCell>
{app.ai_percent != null ? `${app.ai_percent}%` : "-"}
</TableCell>
<TableCell className="tabular-nums">{app.points}</TableCell>
</TableRow>
))
)}
Expand Down
1 change: 1 addition & 0 deletions client/web/src/pages/admin/all-applicants/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface ApplicationListItem {
reviews_assigned: number;
reviews_completed: number;
has_resume: boolean;
points: number;
}

export interface ApplicationListResult {
Expand Down
6 changes: 4 additions & 2 deletions client/web/src/pages/admin/scans/ScansPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect } from "react";

import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useUserStore } from "@/shared/stores/user";
import { usePointsNameStore, useUserStore } from "@/shared/stores";

import { ScannerDialog } from "./components/ScannerDialog";
import { ScanStatsCards } from "./components/ScanStatsCards";
Expand All @@ -24,19 +24,21 @@ export default function ScansPage() {
rebalanceStats,
setActiveScanType,
} = useScansStore();
const fetchPointsName = usePointsNameStore((s) => s.fetchPointsName);

const isSuperAdmin = user?.role === "super_admin";

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

if (typesLoading && scanTypes.length === 0) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
DoorOpen,
Gift,
MoreHorizontal,
ShoppingBag,
UserCheck,
Utensils,
} from "lucide-react";
Expand All @@ -19,6 +20,7 @@ const categoryIcons: Record<ScanTypeCategory, typeof UserCheck> = {
check_in: UserCheck,
meal: Utensils,
swag: Gift,
shop: ShoppingBag,
other: MoreHorizontal,
walk_in: DoorOpen,
};
Expand Down
58 changes: 55 additions & 3 deletions client/web/src/pages/admin/scans/components/ScanTypesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { usePointsNameStore } from "@/shared/stores";

import type { ScanStat, ScanType, ScanTypeCategory } from "../types";
import {
Expand Down Expand Up @@ -72,10 +73,12 @@ export function ScanTypesTable({
}: ScanTypesTableProps) {
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editDisplayName, setEditDisplayName] = useState("");
const [editPoints, setEditPoints] = useState("0");
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
const [pendingNew, setPendingNew] = useState<ScanType | null>(null);
const [rebalanceOpen, setRebalanceOpen] = useState(false);
const editRowRef = useRef<HTMLTableRowElement>(null);
const pointsName = usePointsNameStore((s) => s.pointsName);

// When there's a pending new row, append it so it renders in the table
const effectiveTypes = pendingNew ? [...scanTypes, pendingNew] : scanTypes;
Expand All @@ -90,13 +93,16 @@ export function ScanTypesTable({
setEditingIndex(index);
if (scanTypes[index]) {
setEditDisplayName(scanTypes[index].display_name);
setEditPoints(String(scanTypes[index].points ?? 0));
}
};

const saveDisplayName = useCallback(() => {
if (editingIndex === null) return;

const trimmed = editDisplayName.trim();
const parsedPoints = Number.parseInt(editPoints, 10);
const points = Number.isNaN(parsedPoints) ? 0 : parsedPoints;

// Pending new row — save only if user typed something, otherwise no-op
if (pendingNew) {
Expand All @@ -105,6 +111,7 @@ export function ScanTypesTable({
...pendingNew,
display_name: trimmed,
name: toSnakeCase(trimmed),
points,
};
const updated = [...scanTypes, newType];

Expand All @@ -124,11 +131,20 @@ export function ScanTypesTable({
if (!current) return;

// No change — skip save
if (trimmed === current.display_name) return;
const nameChanged = trimmed !== current.display_name;
if (!nameChanged && points === current.points) return;

// Only re-derive `name` when the display name actually changed — it's the
// key historical scans and scan_stats are keyed by, so a points-only edit
// must not rename the scan type.
const updated = scanTypes.map((st, i) =>
i === editingIndex
? { ...st, display_name: trimmed, name: toSnakeCase(trimmed) }
? {
...st,
display_name: trimmed,
name: nameChanged ? toSnakeCase(trimmed) : st.name,
points,
}
: st,
);

Expand All @@ -139,7 +155,14 @@ export function ScanTypesTable({
}

onSave(updated);
}, [editingIndex, editDisplayName, scanTypes, pendingNew, onSave]);
}, [
editingIndex,
editDisplayName,
editPoints,
scanTypes,
pendingNew,
onSave,
]);

// Ref to avoid stale closures in event listeners
const saveDisplayNameRef = useRef(saveDisplayName);
Expand Down Expand Up @@ -197,10 +220,12 @@ export function ScanTypesTable({
display_name: "",
category: "other",
is_active: true,
points: 0,
};
setPendingNew(newType);
setEditingIndex(scanTypes.length);
setEditDisplayName("");
setEditPoints("0");
};

const handleDelete = (index: number) => {
Expand Down Expand Up @@ -295,6 +320,7 @@ export function ScanTypesTable({
<TableHead className="w-24">Action</TableHead>
<TableHead className="w-48">Name</TableHead>
<TableHead className="w-150">Category</TableHead>
<TableHead className="w-24">{pointsName}</TableHead>
<TableHead className="w-24">Scans</TableHead>
{isSuperAdmin && <TableHead>Active</TableHead>}
</TableRow>
Expand Down Expand Up @@ -375,6 +401,29 @@ export function ScanTypesTable({
})}
</div>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
value={editPoints}
onChange={(e) => setEditPoints(e.target.value)}
onBlur={(e) => {
if (
editRowRef.current?.contains(
e.relatedTarget as Node,
)
)
return;
saveDisplayName();
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
closeEditing();
}
}}
className="h-8 w-20 text-sm font-light shadow-none bg-transparent pl-2 rounded-sm focus-visible:ring-1"
/>
</TableCell>
<TableCell className="tabular-nums">{count}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
Expand Down Expand Up @@ -461,6 +510,9 @@ export function ScanTypesTable({
</Badge>
</div>
</TableCell>
<TableCell className="tabular-nums">
{scanType.points ?? 0}
</TableCell>
<TableCell className="tabular-nums">{count}</TableCell>
{isSuperAdmin && (
<TableCell>
Expand Down
17 changes: 17 additions & 0 deletions client/web/src/pages/admin/scans/components/ScannerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { usePointsNameStore } from "@/shared/stores";

import { useScansStore } from "../store";
import { useQrScanner } from "./useQrScanner";
Expand All @@ -23,6 +24,7 @@ export function ScannerDialog() {
performScan,
clearLastResult,
} = useScansStore();
const pointsName = usePointsNameStore((s) => s.pointsName);

const handleScan = useCallback(
(decodedText: string) => {
Expand Down Expand Up @@ -103,6 +105,21 @@ export function ScannerDialog() {
<XCircle className="size-12" />
)}
<p className="text-lg font-medium">{lastScanResult.message}</p>
{lastScanResult.success &&
(lastScanResult.scan?.points ?? 0) !== 0 && (
<p className="text-sm font-medium">
{(lastScanResult.scan?.points ?? 0) > 0
? `+${lastScanResult.scan?.points}`
: `−${Math.abs(lastScanResult.scan?.points ?? 0)}`}{" "}
{pointsName}
</p>
)}
{lastScanResult.success &&
lastScanResult.scan?.balance !== undefined && (
<p className="text-sm font-light">
Balance: {lastScanResult.scan.balance} {pointsName}
</p>
)}
<Button variant="outline" onClick={handleResume}>
<ScanLine className="mr-2 size-4" />
Scan Next
Expand Down
2 changes: 2 additions & 0 deletions client/web/src/pages/admin/scans/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export const useScansStore = create<ScansState>((set, get) => ({
message = `Already scanned for ${activeScanType.display_name}`;
} else if (res.status === 403) {
message = "User must check in first";
} else if (res.status === 402) {
message = res.error || "Insufficient points";
}

set({
Expand Down
7 changes: 6 additions & 1 deletion client/web/src/pages/admin/scans/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,27 @@ export type ScanTypeCategory =
| "meal"
| "swag"
| "other"
| "walk_in";
| "walk_in"
| "shop";

export interface ScanType {
name: string;
display_name: string;
category: ScanTypeCategory;
is_active: boolean;
points: number;
}

export interface Scan {
id: string;
user_id: string;
scan_type: string;
scanned_by: string;
points: number;
scanned_at: string;
created_at: string;
/** Remaining points balance; present only on shop scans. */
balance?: number;
}

export interface ScanStat {
Expand Down
Loading
Loading