diff --git a/claude.md b/claude.md
index 205eefac..03cfded3 100644
--- a/claude.md
+++ b/claude.md
@@ -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/*`
diff --git a/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx b/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx
index 01f0dccd..b8e71186 100644
--- a/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx
+++ b/client/web/src/pages/admin/all-applicants/AllApplicantsPage.tsx
@@ -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";
@@ -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<
@@ -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(() => {
diff --git a/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx b/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx
index 423ecc0f..b25d75ac 100644
--- a/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx
+++ b/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx
@@ -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";
@@ -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) {
@@ -76,6 +78,9 @@ export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({
{application.status}
+
+ {application.points ?? 0} {pointsName}
+
>
) : null}
diff --git a/client/web/src/pages/admin/all-applicants/components/ApplicationsTable.tsx b/client/web/src/pages/admin/all-applicants/components/ApplicationsTable.tsx
index f7067016..8ad5ca27 100644
--- a/client/web/src/pages/admin/all-applicants/components/ApplicationsTable.tsx
+++ b/client/web/src/pages/admin/all-applicants/components/ApplicationsTable.tsx
@@ -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";
@@ -28,6 +29,8 @@ export const ApplicationsTable = memo(function ApplicationsTable({
selectedId,
onSelectApplication,
}: ApplicationsTableProps) {
+ const pointsName = usePointsNameStore((s) => s.pointsName);
+
return (
{loading && (
@@ -51,12 +54,13 @@ export const ApplicationsTable = memo(function ApplicationsTable({
Created
Updated
AI Percent
+
{pointsName}
{applications.length === 0 ? (
-
+
No applications found
@@ -107,6 +111,7 @@ export const ApplicationsTable = memo(function ApplicationsTable({
{app.ai_percent != null ? `${app.ai_percent}%` : "-"}
+ {app.points}
))
)}
diff --git a/client/web/src/pages/admin/all-applicants/types.ts b/client/web/src/pages/admin/all-applicants/types.ts
index 50f6b836..fe19a7aa 100644
--- a/client/web/src/pages/admin/all-applicants/types.ts
+++ b/client/web/src/pages/admin/all-applicants/types.ts
@@ -30,6 +30,7 @@ export interface ApplicationListItem {
reviews_assigned: number;
reviews_completed: number;
has_resume: boolean;
+ points: number;
}
export interface ApplicationListResult {
diff --git a/client/web/src/pages/admin/scans/ScansPage.tsx b/client/web/src/pages/admin/scans/ScansPage.tsx
index b7db5e03..b27b9f53 100644
--- a/client/web/src/pages/admin/scans/ScansPage.tsx
+++ b/client/web/src/pages/admin/scans/ScansPage.tsx
@@ -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";
@@ -24,6 +24,7 @@ export default function ScansPage() {
rebalanceStats,
setActiveScanType,
} = useScansStore();
+ const fetchPointsName = usePointsNameStore((s) => s.fetchPointsName);
const isSuperAdmin = user?.role === "super_admin";
@@ -31,12 +32,13 @@ export default function ScansPage() {
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 (
diff --git a/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx b/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx
index 37bf6747..c2c6f7a4 100644
--- a/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx
+++ b/client/web/src/pages/admin/scans/components/ScanStatsCards.tsx
@@ -2,6 +2,7 @@ import {
DoorOpen,
Gift,
MoreHorizontal,
+ ShoppingBag,
UserCheck,
Utensils,
} from "lucide-react";
@@ -19,6 +20,7 @@ const categoryIcons: Record = {
check_in: UserCheck,
meal: Utensils,
swag: Gift,
+ shop: ShoppingBag,
other: MoreHorizontal,
walk_in: DoorOpen,
};
diff --git a/client/web/src/pages/admin/scans/components/ScanTypesTable.tsx b/client/web/src/pages/admin/scans/components/ScanTypesTable.tsx
index 9ac11bba..f3b441af 100644
--- a/client/web/src/pages/admin/scans/components/ScanTypesTable.tsx
+++ b/client/web/src/pages/admin/scans/components/ScanTypesTable.tsx
@@ -37,6 +37,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
+import { usePointsNameStore } from "@/shared/stores";
import type { ScanStat, ScanType, ScanTypeCategory } from "../types";
import {
@@ -72,10 +73,12 @@ export function ScanTypesTable({
}: ScanTypesTableProps) {
const [editingIndex, setEditingIndex] = useState(null);
const [editDisplayName, setEditDisplayName] = useState("");
+ const [editPoints, setEditPoints] = useState("0");
const [deleteIndex, setDeleteIndex] = useState(null);
const [pendingNew, setPendingNew] = useState(null);
const [rebalanceOpen, setRebalanceOpen] = useState(false);
const editRowRef = useRef(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;
@@ -90,6 +93,7 @@ export function ScanTypesTable({
setEditingIndex(index);
if (scanTypes[index]) {
setEditDisplayName(scanTypes[index].display_name);
+ setEditPoints(String(scanTypes[index].points ?? 0));
}
};
@@ -97,6 +101,8 @@ export function ScanTypesTable({
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) {
@@ -105,6 +111,7 @@ export function ScanTypesTable({
...pendingNew,
display_name: trimmed,
name: toSnakeCase(trimmed),
+ points,
};
const updated = [...scanTypes, newType];
@@ -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,
);
@@ -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);
@@ -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) => {
@@ -295,6 +320,7 @@ export function ScanTypesTable({
Action
Name
Category
+ {pointsName}
Scans
{isSuperAdmin && Active}
@@ -375,6 +401,29 @@ export function ScanTypesTable({
})}
+
+ 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"
+ />
+
{count}
@@ -461,6 +510,9 @@ export function ScanTypesTable({
+
+ {scanType.points ?? 0}
+
{count}
{isSuperAdmin && (
diff --git a/client/web/src/pages/admin/scans/components/ScannerDialog.tsx b/client/web/src/pages/admin/scans/components/ScannerDialog.tsx
index bc291c38..d732e712 100644
--- a/client/web/src/pages/admin/scans/components/ScannerDialog.tsx
+++ b/client/web/src/pages/admin/scans/components/ScannerDialog.tsx
@@ -10,6 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import { usePointsNameStore } from "@/shared/stores";
import { useScansStore } from "../store";
import { useQrScanner } from "./useQrScanner";
@@ -23,6 +24,7 @@ export function ScannerDialog() {
performScan,
clearLastResult,
} = useScansStore();
+ const pointsName = usePointsNameStore((s) => s.pointsName);
const handleScan = useCallback(
(decodedText: string) => {
@@ -103,6 +105,21 @@ export function ScannerDialog() {
)}
{lastScanResult.message}
+ {lastScanResult.success &&
+ (lastScanResult.scan?.points ?? 0) !== 0 && (
+
+ {(lastScanResult.scan?.points ?? 0) > 0
+ ? `+${lastScanResult.scan?.points}`
+ : `−${Math.abs(lastScanResult.scan?.points ?? 0)}`}{" "}
+ {pointsName}
+
+ )}
+ {lastScanResult.success &&
+ lastScanResult.scan?.balance !== undefined && (
+
+ Balance: {lastScanResult.scan.balance} {pointsName}
+
+ )}