Skip to content

Commit 7c5c001

Browse files
authored
feat: add dynamic options (#409)
* feat: add dynamic options * chore: format * fix: use private procedure
1 parent ae9dea9 commit 7c5c001

7 files changed

Lines changed: 550 additions & 118 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
Warnings:
3+
4+
- You are about to drop the column `station` on the `EventLog` table. All the data in the column will be lost.
5+
- You are about to drop the column `type` on the `EventLog` table. All the data in the column will be lost.
6+
- Added the required column `stationId` to the `EventLog` table without a default value. This is not possible if the table is not empty.
7+
8+
*/
9+
-- AlterTable
10+
ALTER TABLE "EventLog" DROP COLUMN "station";
11+
ALTER TABLE "EventLog" DROP COLUMN "type";
12+
ALTER TABLE "EventLog" ADD COLUMN "stationId" STRING NOT NULL;
13+
14+
-- CreateTable
15+
CREATE TABLE "Station" (
16+
"id" STRING NOT NULL,
17+
"name" STRING NOT NULL,
18+
"option" STRING NOT NULL,
19+
20+
CONSTRAINT "Station_pkey" PRIMARY KEY ("id")
21+
);
22+
23+
-- CreateIndex
24+
CREATE UNIQUE INDEX "Station_name_option_key" ON "Station"("name", "option");
25+
26+
-- AddForeignKey
27+
ALTER TABLE "EventLog" ADD CONSTRAINT "EventLog_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "Station"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,17 @@ model EventLog {
8585
userId String
8686
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
8787
timestamp DateTime @default(now())
88-
station String
89-
type String
88+
stationId String
89+
station Station @relation(fields: [stationId], references: [id])
90+
}
91+
92+
model Station {
93+
id String @id @default(cuid())
94+
name String
95+
option String
96+
eventLogs EventLog[]
97+
98+
@@unique([name, option])
9099
}
91100

92101
model VerificationToken {

src/pages/admin/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ const Admin: NextPage = () => {
8383
description="Access the application grading interface"
8484
href="/admin/grade"
8585
/>
86+
<AdminCard
87+
title="Station Config"
88+
description="Manage food and event scanner options"
89+
href="/admin/station-config"
90+
/>
8691
</div>
8792

8893
<div className="card bg-base-200 shadow-xl p-6">

src/pages/admin/station-config.tsx

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { Role } from "@prisma/client";
2+
import {
3+
GetServerSidePropsContext,
4+
GetServerSidePropsResult,
5+
NextPage,
6+
} from "next";
7+
import { useState } from "react";
8+
import { rbac } from "../../components/RBACWrapper";
9+
import { getServerAuthSession } from "../../server/common/get-server-auth-session";
10+
import { trpc } from "../../utils/trpc";
11+
import { FiCheck, FiEdit2, FiPlus, FiTrash2, FiX } from "react-icons/fi";
12+
import Head from "next/head";
13+
import Drawer from "../../components/Drawer";
14+
15+
type StationWithCount = {
16+
id: string;
17+
name: string;
18+
option: string;
19+
_count: { eventLogs: number };
20+
};
21+
22+
const StationOptionsCard: React.FC<{
23+
title: string;
24+
stationName: string;
25+
options: StationWithCount[];
26+
onAdd: (stationName: string, option: string) => Promise<void>;
27+
onEdit: (id: string, option: string) => Promise<void>;
28+
onDelete: (id: string) => Promise<void>;
29+
isAdding: boolean;
30+
isEditing: boolean;
31+
isDeleting: boolean;
32+
}> = ({
33+
title,
34+
stationName,
35+
options,
36+
onAdd,
37+
onEdit,
38+
onDelete,
39+
isAdding,
40+
isEditing,
41+
isDeleting,
42+
}) => {
43+
const [inputValue, setInputValue] = useState("");
44+
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
45+
const [editingId, setEditingId] = useState<string | null>(null);
46+
const [editValue, setEditValue] = useState("");
47+
48+
const handleAdd = async () => {
49+
if (!inputValue.trim()) return;
50+
await onAdd(stationName, inputValue.trim());
51+
setInputValue("");
52+
};
53+
54+
const handleDelete = async (id: string) => {
55+
await onDelete(id);
56+
setDeleteConfirm(null);
57+
};
58+
59+
const handleStartEdit = (station: StationWithCount) => {
60+
setEditingId(station.id);
61+
setEditValue(station.option);
62+
};
63+
64+
const handleSaveEdit = async () => {
65+
if (!editingId || !editValue.trim()) return;
66+
await onEdit(editingId, editValue.trim());
67+
setEditingId(null);
68+
setEditValue("");
69+
};
70+
71+
const handleCancelEdit = () => {
72+
setEditingId(null);
73+
setEditValue("");
74+
};
75+
76+
return (
77+
<div className="card bg-base-200 shadow-xl">
78+
<div className="card-body">
79+
<h2 className="card-title text-xl mb-4">{title}</h2>
80+
81+
<div className="flex gap-2 mb-4">
82+
<input
83+
type="text"
84+
placeholder={`New ${stationName} option...`}
85+
className="input input-bordered flex-1"
86+
value={inputValue}
87+
onChange={(e) => setInputValue(e.target.value)}
88+
onKeyDown={(e) => {
89+
if (e.key === "Enter") handleAdd();
90+
}}
91+
/>
92+
<button
93+
className="btn btn-primary"
94+
onClick={handleAdd}
95+
disabled={!inputValue.trim() || isAdding}
96+
>
97+
<FiPlus className="w-5 h-5" />
98+
Add
99+
</button>
100+
</div>
101+
102+
<div className="flex flex-wrap gap-2">
103+
{options.length === 0 ? (
104+
<p className="text-gray-500">No {stationName} options configured</p>
105+
) : (
106+
options.map((station) => (
107+
<div
108+
key={station.id}
109+
className="flex items-center gap-2 px-2 py-1 rounded-md bg-base-300 text-sm"
110+
>
111+
{editingId === station.id ? (
112+
<>
113+
<input
114+
type="text"
115+
className="input input-xs input-bordered w-24"
116+
value={editValue}
117+
onChange={(e) => setEditValue(e.target.value)}
118+
onKeyDown={(e) => {
119+
if (e.key === "Enter") handleSaveEdit();
120+
if (e.key === "Escape") handleCancelEdit();
121+
}}
122+
autoFocus
123+
/>
124+
<button
125+
className="btn btn-success btn-xs btn-square"
126+
onClick={handleSaveEdit}
127+
disabled={isEditing || !editValue.trim()}
128+
>
129+
<FiCheck className="w-3 h-3" />
130+
</button>
131+
<button
132+
className="btn btn-ghost btn-xs btn-square"
133+
onClick={handleCancelEdit}
134+
>
135+
<FiX className="w-3 h-3" />
136+
</button>
137+
</>
138+
) : (
139+
<>
140+
<span>{station.option}</span>
141+
{station._count.eventLogs > 0 ? (
142+
<button
143+
className="btn btn-ghost btn-xs btn-square"
144+
onClick={() => handleStartEdit(station)}
145+
title="Edit (has references)"
146+
>
147+
<FiEdit2 className="w-3 h-3" />
148+
</button>
149+
) : deleteConfirm === station.id ? (
150+
<div className="flex gap-1">
151+
<button
152+
className="btn btn-error btn-xs"
153+
onClick={() => handleDelete(station.id)}
154+
disabled={isDeleting}
155+
>
156+
Yes
157+
</button>
158+
<button
159+
className="btn btn-ghost btn-xs"
160+
onClick={() => setDeleteConfirm(null)}
161+
>
162+
No
163+
</button>
164+
</div>
165+
) : (
166+
<button
167+
className="btn btn-ghost btn-xs btn-square"
168+
onClick={() => setDeleteConfirm(station.id)}
169+
>
170+
<FiTrash2 className="w-3 h-3" />
171+
</button>
172+
)}
173+
</>
174+
)}
175+
</div>
176+
))
177+
)}
178+
</div>
179+
</div>
180+
</div>
181+
);
182+
};
183+
184+
const StationConfig: NextPage = () => {
185+
const utils = trpc.useUtils();
186+
const { data: stations, isPending } = trpc.scanner.listStations.useQuery();
187+
188+
const createStation = trpc.scanner.createStation.useMutation({
189+
onSuccess: () => {
190+
utils.scanner.listStations.invalidate();
191+
},
192+
});
193+
194+
const updateStation = trpc.scanner.updateStation.useMutation({
195+
onSuccess: () => {
196+
utils.scanner.listStations.invalidate();
197+
},
198+
});
199+
200+
const deleteStation = trpc.scanner.deleteStation.useMutation({
201+
onSuccess: () => {
202+
utils.scanner.listStations.invalidate();
203+
},
204+
});
205+
206+
const handleAdd = async (stationName: string, option: string) => {
207+
await createStation.mutateAsync({ name: stationName, option });
208+
};
209+
210+
const handleEdit = async (id: string, option: string) => {
211+
await updateStation.mutateAsync({ id, option });
212+
};
213+
214+
const handleDelete = async (id: string) => {
215+
await deleteStation.mutateAsync({ id });
216+
};
217+
218+
return (
219+
<>
220+
<Head>
221+
<title>Station Config - DeltaHacks</title>
222+
</Head>
223+
<Drawer>
224+
<main className="px-7 py-16 sm:px-14 md:w-10/12 lg:pl-20 2xl:w-8/12 2xl:pt-20 mx-auto max-w-4xl">
225+
<h1 className="mb-8 text-2xl font-semibold leading-tight text-black dark:text-white sm:text-3xl lg:text-5xl 2xl:text-6xl text-center">
226+
Station Config
227+
</h1>
228+
229+
{isPending ? (
230+
<div className="flex justify-center items-center py-12">
231+
<progress className="progress progress-primary w-56"></progress>
232+
</div>
233+
) : (
234+
<div className="space-y-8">
235+
<StationOptionsCard
236+
title="Food Options"
237+
stationName="food"
238+
options={stations?.food || []}
239+
onAdd={handleAdd}
240+
onEdit={handleEdit}
241+
onDelete={handleDelete}
242+
isAdding={createStation.isPending}
243+
isEditing={updateStation.isPending}
244+
isDeleting={deleteStation.isPending}
245+
/>
246+
<StationOptionsCard
247+
title="Event Options"
248+
stationName="events"
249+
options={stations?.events || []}
250+
onAdd={handleAdd}
251+
onEdit={handleEdit}
252+
onDelete={handleDelete}
253+
isAdding={createStation.isPending}
254+
isEditing={updateStation.isPending}
255+
isDeleting={deleteStation.isPending}
256+
/>
257+
</div>
258+
)}
259+
</main>
260+
</Drawer>
261+
</>
262+
);
263+
};
264+
265+
export async function getServerSideProps(context: GetServerSidePropsContext) {
266+
let output: GetServerSidePropsResult<Record<string, unknown>> = { props: {} };
267+
output = rbac(
268+
await getServerAuthSession(context),
269+
[Role.ADMIN],
270+
undefined,
271+
output,
272+
);
273+
return output;
274+
}
275+
276+
export default StationConfig;

0 commit comments

Comments
 (0)