-
Notifications
You must be signed in to change notification settings - Fork 0
[STU-134] Interview management page for staff #31
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b02bd24
0e41959
5668efb
43eb683
d1ca2b3
6e367c8
115ddc4
8d20b3f
585bd1d
5889012
66d1a06
0371b99
1703dd9
7c2e2d8
75305fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import { notFound } from "next/navigation"; | ||
| import Link from "next/link"; | ||
| import type { Route } from "next"; | ||
| import { requireRoleCapability } from "@/modules/auth/session"; | ||
| import { FactPanel } from "@/modules/workspace/DetailPanels"; | ||
| import { WorkspaceShell } from "@/modules/workspace/WorkspaceShell"; | ||
| import { getStaffInterviewDetail } from "@/modules/workspace/data"; | ||
| import { updateInterviewStatusAction } from "@/modules/requests/interview-actions"; | ||
| import { Button } from "@/components/ui/button"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| function statusLabel(status: number | null | undefined) { | ||
| if (status === 1) return "Completed"; | ||
| if (status === 2) return "Cancelled"; | ||
| return "Scheduled"; | ||
| } | ||
|
|
||
| export default async function StaffInterviewDetailPage({ | ||
| params, | ||
| searchParams | ||
| }: { | ||
| params: Promise<{ id: string }>; | ||
| searchParams: Promise<{ notice?: string }>; | ||
| }) { | ||
| const session = await requireRoleCapability("staff", "request.interview"); | ||
| const { id } = await params; | ||
| const { notice } = await searchParams; | ||
| const interview = await getStaffInterviewDetail(id, Number(session.id)); | ||
|
|
||
| if (!interview) { | ||
| notFound(); | ||
| } | ||
|
|
||
| const facts = [ | ||
| { label: "Candidate", value: interview.candidate?.candidate_name }, | ||
| { label: "Email", value: interview.candidate?.candidate_email }, | ||
| { label: "Phone", value: interview.candidate?.candidate_phone }, | ||
| { label: "Request", value: interview.request?.request_position_title }, | ||
| { label: "Company", value: interview.request?.company?.company_name }, | ||
| { label: "Scheduled At", value: interview.interview_at?.toLocaleString() }, | ||
| { label: "Status", value: statusLabel(interview.status) }, | ||
| { label: "Staff", value: interview.staff?.staff_name }, | ||
| { label: "Internal Note", value: interview.internal_note }, | ||
| { label: "Interview Note", value: interview.interview_note } | ||
| ]; | ||
|
|
||
| return ( | ||
| <WorkspaceShell | ||
| session={session} | ||
| eyebrow="Staff / Interviews" | ||
| title={interview.candidate?.candidate_name ?? "Interview Detail"} | ||
| metrics={[]} | ||
| > | ||
| <FactPanel title="Interview Details" facts={facts} /> | ||
|
|
||
| <section className="detailPanel"> | ||
| <h2>Actions</h2> | ||
| <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> | ||
| {interview.status !== 1 && ( | ||
| <form action={updateInterviewStatusAction}> | ||
| <input type="hidden" name="interview_uuid" value={interview.request_interview_uuid} /> | ||
| <input type="hidden" name="status" value={1} /> | ||
| <Button type="submit" variant="default">Mark Completed</Button> | ||
| </form> | ||
| )} | ||
| {interview.status !== 2 && ( | ||
| <form action={updateInterviewStatusAction}> | ||
| <input type="hidden" name="interview_uuid" value={interview.request_interview_uuid} /> | ||
| <input type="hidden" name="status" value={2} /> | ||
| <Button type="submit" variant="outline">Mark Cancelled</Button> | ||
| </form> | ||
| )} | ||
| {interview.status !== 0 && interview.status !== null && ( | ||
| <form action={updateInterviewStatusAction}> | ||
| <input type="hidden" name="interview_uuid" value={interview.request_interview_uuid} /> | ||
| <input type="hidden" name="status" value={0} /> | ||
| <Button type="submit" variant="secondary">Reset to Scheduled</Button> | ||
| </form> | ||
| )} | ||
| </div> | ||
| </section> | ||
|
|
||
| <section className="detailPanel"> | ||
| <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}> | ||
| {interview.candidate?.candidate_id && ( | ||
| <Link href={`/staff/candidates?candidate=${interview.candidate.candidate_id}` as Route}> | ||
| <Button variant="outline">View Candidate</Button> | ||
| </Link> | ||
| )} | ||
| {interview.request?.request_uuid && ( | ||
| <Link href={`/staff/requests/${interview.request.request_uuid}` as Route}> | ||
| <Button variant="outline">View Request</Button> | ||
| </Link> | ||
| )} | ||
| <Link href={"/staff/interviews" as Route}> | ||
| <Button variant="ghost">Back to Interviews</Button> | ||
| </Link> | ||
| </div> | ||
| </section> | ||
|
|
||
| {notice && ( | ||
| <section className="detailPanel"> | ||
| <p className="notice"> | ||
| {notice === "interview-updated" && "Interview updated successfully."} | ||
| {notice === "not-found" && "Interview not found."} | ||
| {notice === "missing-fields" && "Missing required fields."} | ||
| </p> | ||
| </section> | ||
| )} | ||
| </WorkspaceShell> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import type { Route } from "next"; | ||
| import { requireRoleCapability } from "@/modules/auth/session"; | ||
| import { DataTable } from "@/modules/workspace/DataTable"; | ||
| import { WorkspaceShell } from "@/modules/workspace/WorkspaceShell"; | ||
| import { getStaffInterviewRows } from "@/modules/workspace/data"; | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| export default async function StaffInterviewsPage() { | ||
| const session = await requireRoleCapability("staff", "request.interview"); | ||
| const rows = await getStaffInterviewRows(Number(session.id)); | ||
|
|
||
| return ( | ||
| <WorkspaceShell session={session} eyebrow="Staff" title="Interviews" metrics={[]}> | ||
| <DataTable | ||
| title="Interview Pipeline" | ||
| description="Interviews scheduled and managed by you." | ||
| rows={rows} | ||
| rowHref={(row) => `/staff/interviews/${row.id}` as Route} | ||
| columns={[ | ||
| { key: "candidate", label: "Candidate", render: (row) => <strong>{row.candidate}</strong> }, | ||
| { key: "request", label: "Request", render: (row) => row.requestTitle }, | ||
| { key: "scheduled", label: "Scheduled", render: (row) => row.scheduledAt }, | ||
| { key: "status", label: "Status", render: (row) => row.status }, | ||
| { key: "note", label: "Note", render: (row) => row.note.slice(0, 80) } | ||
| ]} | ||
| /> | ||
| </WorkspaceShell> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -108,7 +108,7 @@ export async function updateInterviewAction(formData: FormData) { | |
| const now = new Date(); | ||
|
|
||
| const data: Record<string, unknown> = { updated_at: now }; | ||
| if (Number.isInteger(status)) data.status = status; | ||
| if (status === 2 || status === 3) data.status = status; | ||
| if (interviewNote !== null) data.interview_note = interviewNote; | ||
| if (internalNote !== null) data.internal_note = internalNote; | ||
|
|
||
|
|
@@ -127,3 +127,34 @@ export async function updateInterviewAction(formData: FormData) { | |
| revalidatePath(basePath); | ||
| redirect(`${detailPath}?notice=interview-updated` as Route); | ||
| } | ||
|
|
||
|
|
||
| export async function updateInterviewStatusAction(formData: FormData) { | ||
| const session = await requireCapability("request.interview"); | ||
|
|
||
| const interviewUuid = String(formData.get("interview_uuid") ?? "").trim(); | ||
| const status = Number(formData.get("status")); | ||
| const basePath = session.role === "admin" ? "/admin/interviews" : "/staff/interviews"; | ||
|
|
||
| if (!interviewUuid || !Number.isInteger(status)) { | ||
| redirect(`${basePath}?notice=missing-fields` as Route); | ||
| } | ||
|
Comment on lines
+139
to
+141
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Restrict status to supported values before persisting.
Suggested fix- if (!interviewUuid || !Number.isInteger(status)) {
+ const allowedStatuses = new Set([0, 1, 2]);
+ if (!interviewUuid || !allowedStatuses.has(status)) {
redirect(`${basePath}?notice=missing-fields` as Route);
}Also applies to: 154-155 🤖 Prompt for AI Agents |
||
|
|
||
| if (session.role === "staff") { | ||
| const owned = await prisma.request_interview.findFirst({ | ||
| where: { request_interview_uuid: interviewUuid, staff_id: Number(session.id) }, | ||
| select: { request_interview_uuid: true } | ||
| }); | ||
| if (!owned) redirect(`${basePath}?notice=not-found` as Route); | ||
| } | ||
|
Comment on lines
+143
to
+149
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle missing interview for admin path to avoid unhandled update failure. For non-staff users, no existence check is performed before Suggested fix- if (session.role === "staff") {
- const owned = await prisma.request_interview.findFirst({
- where: { request_interview_uuid: interviewUuid, staff_id: Number(session.id) },
- select: { request_interview_uuid: true }
- });
- if (!owned) redirect(`${basePath}?notice=not-found` as Route);
- }
+ const existing = await prisma.request_interview.findFirst({
+ where:
+ session.role === "staff"
+ ? { request_interview_uuid: interviewUuid, staff_id: Number(session.id) }
+ : { request_interview_uuid: interviewUuid },
+ select: { request_interview_uuid: true }
+ });
+ if (!existing) redirect(`${basePath}?notice=not-found` as Route);Also applies to: 152-155 🤖 Prompt for AI Agents |
||
|
|
||
| const now = new Date(); | ||
| await prisma.request_interview.update({ | ||
| where: { request_interview_uuid: interviewUuid }, | ||
| data: { status, updated_at: now } | ||
| }); | ||
|
|
||
| revalidatePath(basePath); | ||
| revalidatePath(`${basePath}/${interviewUuid}`); | ||
| redirect(`${basePath}/${interviewUuid}?notice=interview-updated` as Route); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make content bottom padding safe-area aware to prevent clipped last items.
The tab bar accounts for
safe-area-inset-bottom, but these content containers use fixed bottom padding. On iOS devices with larger insets, the final rows can sit under the bar.Proposed fix
`@media` (max-width: 768px) { .workspaceStage { width: auto; - padding: 10px 10px 76px; + padding: 10px 10px calc(76px + env(safe-area-inset-bottom, 0px)); } } `@media` (max-width: 680px) { .hubMain { gap: 12px; - padding: 10px 10px 76px; + padding: 10px 10px calc(76px + env(safe-area-inset-bottom, 0px)); } .journeyHome { - padding: 10px 10px 76px; + padding: 10px 10px calc(76px + env(safe-area-inset-bottom, 0px)); } .studentOSMain { - padding: 10px 10px 76px; + padding: 10px 10px calc(76px + env(safe-area-inset-bottom, 0px)); } } `@media` (max-width: 480px) { .workspaceStage { - padding: 8px 8px 72px; + padding: 8px 8px calc(72px + env(safe-area-inset-bottom, 0px)); } }Also applies to: 6022-6022, 6076-6076, 6117-6117, 9309-9309
🤖 Prompt for AI Agents