Skip to content

Commit b7f87c8

Browse files
authored
Feat/staff change award (#204)
* feat: add awardAudit table to schema for tracking award changes * feat: create award_audit table for tracking award changes and add related foreign key constraints * feat: implement award change functionality with dialog, form, and history components * refactor: update award options to use English labels and remove redundant display of English names in history * fix: enhance award update button logic to prevent submission with unchanged award * feat: add "None" option to award selection and improve dialog layout for better user experience * fix: update award query state key and sort award history by change date * feat: implement AwardChangeContext for managing award change state across components * refactor: adjust dashboard layout by removing TimeLeftCard and updating grid structure * chore: fix lint error
1 parent e78212a commit b7f87c8

File tree

13 files changed

+1874
-5
lines changed

13 files changed

+1874
-5
lines changed

apps/staff/src/app/(protected)/dashboard/page.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { TeamsLineChart } from "@/app/(protected)/dashboard/_components/teams-line-chart"
22
import TeamNumberCard from "@/app/(protected)/dashboard/_components/teams-number-chart"
33
import { TeamsPieChart } from "@/app/(protected)/dashboard/_components/teams-pie-chart"
4-
import TimeLeftCard from "@/app/(protected)/dashboard/_components/time-left-card"
54
import { teamsData } from "@/app/(protected)/dashboard/_lib/user-v-done"
65

76
async function Dashboard() {
@@ -26,7 +25,7 @@ async function Dashboard() {
2625
<TeamsPieChart data={data} />
2726
</div>
2827

29-
<div className="grid grid-cols-1 gap-6 sm:grid-cols-[1fr_1fr_0.6fr]">
28+
<div className="grid grid-cols-1 gap-6 sm:grid-cols-[1fr_1fr]">
3029
<TeamNumberCard
3130
number={data.summary.totalRegistered}
3231
title="Overall Registered Teams"
@@ -39,7 +38,6 @@ async function Dashboard() {
3938
description={`${data.summary.submissionRate}% submission rate`}
4039
variant="submitted"
4140
/>
42-
<TimeLeftCard date={new Date("2025-09-15T23:59:00")} />
4341
</div>
4442
</div>
4543
</div>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"use server"
2+
3+
import { protectedActionContext } from "@/lib/orpc/actionable"
4+
import { protectedProcedure } from "@/lib/orpc/procedures"
5+
import { db, teams, awardAudit, user } from "@workspace/db"
6+
import { eq } from "@workspace/db/orm"
7+
import { revalidateTag } from "next/cache"
8+
import { z } from "zod"
9+
10+
// Input validation schema for the award change form
11+
const awardChangeInputSchema = z.object({
12+
teamId: z.string().uuid(),
13+
newAward: z.string().min(1, "Award cannot be empty"),
14+
reason: z.string().optional(),
15+
})
16+
17+
export const submitAwardChange = protectedProcedure
18+
.input(awardChangeInputSchema)
19+
.handler(async ({ input, context }) => {
20+
try {
21+
const currentTeam = await db
22+
.select({ award: teams.award })
23+
.from(teams)
24+
.where(eq(teams.id, input.teamId))
25+
.limit(1)
26+
27+
if (currentTeam.length === 0) {
28+
return {
29+
success: false,
30+
message: "Team not found",
31+
}
32+
}
33+
34+
const oldAward = currentTeam[0].award
35+
const newAward = input.newAward
36+
37+
if (oldAward === newAward) {
38+
return {
39+
success: false,
40+
message: "Award value hasn't changed",
41+
}
42+
}
43+
44+
const result = await db.transaction(async (tx) => {
45+
const [updatedTeam] = await tx
46+
.update(teams)
47+
.set({
48+
award: newAward,
49+
updatedAt: new Date(),
50+
})
51+
.where(eq(teams.id, input.teamId))
52+
.returning()
53+
54+
const [auditRecord] = await tx
55+
.insert(awardAudit)
56+
.values({
57+
teamId: input.teamId,
58+
oldAward,
59+
newAward,
60+
changedBy: context.session.user.id,
61+
reason: input.reason || null,
62+
})
63+
.returning()
64+
65+
return { updatedTeam, auditRecord }
66+
})
67+
68+
revalidateTag("team-awards")
69+
70+
return {
71+
success: true,
72+
message: "Award updated successfully",
73+
data: result,
74+
}
75+
} catch (error) {
76+
console.error("Error updating award:", error)
77+
return {
78+
success: false,
79+
message: "Failed to update award",
80+
error: error instanceof Error ? error.message : "Unknown error",
81+
}
82+
}
83+
})
84+
.actionable({
85+
context: protectedActionContext,
86+
})
87+
88+
export const getAwardAuditHistory = protectedProcedure
89+
.input(
90+
z.object({
91+
teamId: z.string().uuid(),
92+
})
93+
)
94+
.handler(async ({ input }) => {
95+
try {
96+
const auditHistory = await db
97+
.select({
98+
id: awardAudit.id,
99+
oldAward: awardAudit.oldAward,
100+
newAward: awardAudit.newAward,
101+
changedAt: awardAudit.changedAt,
102+
reason: awardAudit.reason,
103+
changedBy: awardAudit.changedBy,
104+
user: {
105+
id: user.id,
106+
name: user.name,
107+
displayUsername: user.displayUsername,
108+
username: user.username,
109+
},
110+
})
111+
.from(awardAudit)
112+
.leftJoin(user, eq(user.id, awardAudit.changedBy))
113+
.where(eq(awardAudit.teamId, input.teamId))
114+
.orderBy(awardAudit.changedAt)
115+
116+
return {
117+
auditHistory,
118+
}
119+
} catch (error) {
120+
console.error("Error fetching award audit history:", error)
121+
return {
122+
auditHistory: [],
123+
error: error instanceof Error ? error.message : "Unknown error",
124+
}
125+
}
126+
})
127+
.actionable({
128+
context: protectedActionContext,
129+
})
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Award options with English values in UPPER case
2+
export const awardOptions = [
3+
{ value: "NONE", label: "None", english: "None" },
4+
{ value: "REGISTERED", label: "Registered", english: "Registered" },
5+
{ value: "ROUND_1_PARTICIPANT", label: "Round 1 Participant", english: "Round 1 Participant" },
6+
{ value: "ROUND_2_PARTICIPANT", label: "Round 2 Participant", english: "Round 2 Participant" },
7+
{ value: "HONORABLE_MENTION", label: "Honorable Mention", english: "Honorable Mention" },
8+
{ value: "3RD_PLACE", label: "3rd Place", english: "3rd Place" },
9+
{ value: "2ND_PLACE", label: "2nd Place", english: "2nd Place" },
10+
{ value: "1ST_PLACE", label: "1st Place", english: "1st Place" },
11+
]
12+
13+
// Helper function to get award display info
14+
export function getAwardDisplay(award: string) {
15+
const option = awardOptions.find((opt) => opt.value === award)
16+
return option || { label: award, english: award }
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createContext, useContext } from "react"
2+
3+
export interface AwardChangeContextValue {
4+
teamId: string
5+
currentAward: string
6+
teamName: string
7+
}
8+
9+
export const AwardChangeContext = createContext<AwardChangeContextValue | undefined>(undefined)
10+
11+
export function useAwardChangeContext(): AwardChangeContextValue {
12+
const context = useContext(AwardChangeContext)
13+
if (!context) {
14+
throw new Error("useAwardChangeContext must be used within an AwardChangeContext.Provider")
15+
}
16+
return context
17+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"use client"
2+
3+
import { AwardChangeContext } from "@/app/(protected)/team-award/_components/award-change/context"
4+
import AwardChangeFormParent from "@/app/(protected)/team-award/_components/award-change/form"
5+
import AwardHistory from "@/app/(protected)/team-award/_components/award-change/history"
6+
import { Button } from "@/components/ui/button"
7+
import {
8+
Dialog,
9+
DialogContent,
10+
DialogDescription,
11+
DialogHeader,
12+
DialogTitle,
13+
DialogTrigger,
14+
} from "@/components/ui/dialog"
15+
import { Trophy } from "lucide-react"
16+
import { parseAsString, useQueryState } from "nuqs"
17+
18+
type AwardChangeDialogProps = {
19+
teamId: string
20+
currentAward: string
21+
teamName: string
22+
}
23+
24+
function AwardChangeDialog(props: AwardChangeDialogProps) {
25+
const [award, setAward] = useQueryState("award-team", parseAsString.withDefault(""))
26+
27+
const isOpen = award === props.teamId
28+
29+
return (
30+
<Dialog open={isOpen} onOpenChange={(open) => setAward(open ? props.teamId : "")}>
31+
<DialogTrigger asChild>
32+
<Button variant="outline" size="icon">
33+
<Trophy className="h-4 w-4" />
34+
</Button>
35+
</DialogTrigger>
36+
<DialogContent className="h-[90vh] max-w-[98vw] p-0 md:max-w-[85vw]">
37+
<DialogHeader className="px-4 pt-4" hidden>
38+
<DialogTitle>Change Award</DialogTitle>
39+
<DialogDescription>Update team award and view change history.</DialogDescription>
40+
</DialogHeader>
41+
<AwardChangeContext.Provider
42+
value={{ teamId: props.teamId, currentAward: props.currentAward, teamName: props.teamName }}>
43+
<div className="grid h-[calc(90vh-80px)] min-h-0 w-full grid-rows-2 transition-all md:grid-cols-[2fr_1fr] md:grid-rows-1">
44+
<div className="min-h-0 border-r p-4 md:h-full md:overflow-y-auto">
45+
<div className="mb-4">
46+
<h2 className="text-lg font-semibold">Change Award</h2>
47+
<p className="text-muted-foreground text-sm">Update award for team: {props.teamName}</p>
48+
</div>
49+
<AwardChangeFormParent closeDialog={() => setAward("")} />
50+
</div>
51+
<div className="min-h-0 px-4 md:h-full lg:p-4">
52+
<h3 className="px-1 pb-2 pt-20 text-lg font-semibold md:pt-0">Award Change History</h3>
53+
<div className="h-[calc(100%-100px)] overflow-y-auto pr-2 md:h-[calc(100%-40px)] lg:h-[calc(100%-32px)]">
54+
<AwardHistory />
55+
</div>
56+
</div>
57+
</div>
58+
</AwardChangeContext.Provider>
59+
</DialogContent>
60+
</Dialog>
61+
)
62+
}
63+
64+
export default AwardChangeDialog

0 commit comments

Comments
 (0)