diff --git a/app/(app)/projects/[id]/stats/careers-toggle.tsx b/app/(app)/projects/[id]/stats/careers-toggle.tsx new file mode 100644 index 00000000..7dcb1b86 --- /dev/null +++ b/app/(app)/projects/[id]/stats/careers-toggle.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { setCareersEnabled } from "@/app/actions/careers"; + +interface CareersToggleProps { + projectId: string; + initialEnabled: boolean; +} + +export function CareersToggle({ projectId, initialEnabled }: CareersToggleProps) { + const [enabled, setEnabled] = useState(initialEnabled); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function flip() { + const next = !enabled; + setError(null); + startTransition(async () => { + const res = await setCareersEnabled({ projectId, enabled: next }); + if (!res.ok) { + setError(res.error); + return; + } + setEnabled(res.enabled); + }); + } + + return ( +
+ + {error &&

{error}

} +
+ ); +} diff --git a/app/(app)/projects/[id]/stats/careers/applications.tsx b/app/(app)/projects/[id]/stats/careers/applications.tsx new file mode 100644 index 00000000..dcad6a94 --- /dev/null +++ b/app/(app)/projects/[id]/stats/careers/applications.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { setApplicationStatus } from "@/app/actions/careers"; +import { + APPLICATION_STATUSES, + APPLICATION_STATUS_LABEL, + type ApplicationStatus, +} from "@/lib/careers/jobs"; + +export interface ApplicationRow { + id: string; + job_id: string; + job_title: string; + full_name: string; + email: string; + link: string | null; + status: ApplicationStatus; + created_at: string; +} + +const STATUS_STYLE: Record = { + new: "bg-blue-500/15 text-blue-600", + shortlisted: "bg-yellow-500/15 text-yellow-700", + accepted: "bg-green-500/15 text-green-600", + rejected: "bg-[var(--color-bg)] text-[var(--color-muted)]", +}; + +export function Applications({ + projectId, + applications, +}: { + projectId: string; + applications: ApplicationRow[]; +}) { + const [filter, setFilter] = useState("all"); + const [job, setJob] = useState("all"); + + const jobs = Array.from( + new Map(applications.map((a) => [a.job_id, a.job_title])).entries(), + ); + + const shown = applications.filter( + (a) => + (filter === "all" || a.status === filter) && (job === "all" || a.job_id === job), + ); + + function countFor(status: ApplicationStatus) { + return applications.filter((a) => a.status === status).length; + } + + return ( +
+
+
+

Applicants

+

+ Name, email, and one link — everything the widget collects. Shortlist, + accept, or reject each one. +

+
+ {jobs.length > 1 && ( + + )} +
+ +
+ setFilter("all")} + /> + {APPLICATION_STATUSES.map((s) => ( + setFilter(s)} + /> + ))} +
+ + {shown.length === 0 ? ( +
+

+ {applications.length === 0 + ? "No applicants yet." + : "No applicants match those filters."} +

+
+ ) : ( +
+ + + + + + + + + + + + + {shown.map((app) => ( + + ))} + +
ApplicantRoleLinkReceivedStatusDecision
+
+ )} +
+ ); +} + +function FilterChip({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function Row({ projectId, app }: { projectId: string; app: ApplicationRow }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function change(status: ApplicationStatus) { + setError(null); + startTransition(async () => { + const res = await setApplicationStatus({ + projectId, + applicationId: app.id, + status, + }); + if (!res.ok) setError(res.error); + else router.refresh(); + }); + } + + return ( + + +
{app.full_name}
+ + {app.email} + + {error &&
{error}
} + + {app.job_title} + + {app.link ? ( + + {app.link.replace(/^https?:\/\//, "").slice(0, 48)} + + ) : ( + — + )} + + + {new Date(app.created_at).toLocaleDateString()} + + + + {APPLICATION_STATUS_LABEL[app.status]} + + + +
+ {app.status !== "shortlisted" && ( + + )} + {app.status !== "accepted" && ( + + )} + {app.status !== "rejected" && ( + + )} + {app.status !== "new" && ( + + )} +
+ + + ); +} diff --git a/app/(app)/projects/[id]/stats/careers/job-manager.tsx b/app/(app)/projects/[id]/stats/careers/job-manager.tsx new file mode 100644 index 00000000..de6ef840 --- /dev/null +++ b/app/(app)/projects/[id]/stats/careers/job-manager.tsx @@ -0,0 +1,437 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { + deleteJobPosting, + saveJobPosting, + setJobStatus, +} from "@/app/actions/careers"; +import { + EMPLOYMENT_TYPES, + WORKPLACES, + WORKPLACE_LABEL, + type JobStatus, + type Workplace, + workplaceSummary, +} from "@/lib/careers/jobs"; + +export interface JobRow { + id: string; + slug: string; + title: string; + department: string | null; + location: string | null; + employment_type: string; + workplace: Workplace; + compensation: string | null; + apply_url: string | null; + overview: string | null; + responsibilities: string[]; + qualifications: string[]; + status: JobStatus; + sort_order: number; + application_count: number; +} + +const STATUS_LABEL: Record = { + draft: "Draft", + open: "Open", + closed: "Closed", +}; + +export function JobManager({ + projectId, + jobs, + hostedBoardUrl, +}: { + projectId: string; + jobs: JobRow[]; + hostedBoardUrl: string; +}) { + const [editing, setEditing] = useState(null); + + return ( +
+
+
+

Job postings

+

+ Open roles appear in the widget on your site and on your{" "} + + hosted board + + . Drafts stay private. +

+
+ {editing === null && ( + + )} +
+ + {editing !== null && ( + setEditing(null)} + /> + )} + + {jobs.length === 0 && editing === null ? ( +
+

+ No roles yet. Add one and set it to Open to publish it. +

+
+ ) : ( + jobs.map((job) => ( + setEditing(job)} + busy={editing !== null} + /> + )) + )} +
+ ); +} + +function JobCard({ + projectId, + job, + onEdit, + busy, +}: { + projectId: string; + job: JobRow; + onEdit: () => void; + busy: boolean; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(false); + + function changeStatus(status: JobStatus) { + setError(null); + startTransition(async () => { + const res = await setJobStatus({ projectId, jobId: job.id, status }); + if (!res.ok) setError(res.error); + else router.refresh(); + }); + } + + function remove() { + setError(null); + startTransition(async () => { + const res = await deleteJobPosting({ projectId, jobId: job.id }); + if (!res.ok) setError(res.error); + else router.refresh(); + }); + } + + const meta = [ + job.department, + workplaceSummary(job.workplace, job.location), + job.employment_type, + job.compensation, + ] + .filter(Boolean) + .join(" · "); + + return ( +
+
+
+

{job.title}

+

{meta || "No details set"}

+
+
+ + {STATUS_LABEL[job.status]} + + + {job.application_count} application{job.application_count === 1 ? "" : "s"} + +
+
+ +
+ + {job.status !== "open" && ( + + )} + {job.status === "open" && ( + + )} + {confirming ? ( + <> + + + + ) : ( + + )} +
+ {confirming && ( +

+ Deleting removes its {job.application_count} application + {job.application_count === 1 ? "" : "s"} too. +

+ )} + {error &&

{error}

} +
+ ); +} + +function JobForm({ + projectId, + job, + onDone, +}: { + projectId: string; + job: JobRow | null; + onDone: () => void; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function submit(formData: FormData) { + setError(null); + startTransition(async () => { + const res = await saveJobPosting({ + projectId, + jobId: job?.id, + title: String(formData.get("title") ?? ""), + department: String(formData.get("department") ?? ""), + location: String(formData.get("location") ?? ""), + employmentType: String(formData.get("employmentType") ?? ""), + workplace: String(formData.get("workplace") ?? "onsite") as Workplace, + compensation: String(formData.get("compensation") ?? ""), + applyUrl: String(formData.get("applyUrl") ?? ""), + overview: String(formData.get("overview") ?? ""), + responsibilities: String(formData.get("responsibilities") ?? ""), + qualifications: String(formData.get("qualifications") ?? ""), + status: String(formData.get("status") ?? "draft") as JobStatus, + sortOrder: Number(formData.get("sortOrder") ?? 0), + }); + if (!res.ok) { + setError(res.error); + return; + } + onDone(); + router.refresh(); + }); + } + + return ( +
+
+ + + + + + + + + + + + + + + + + + + + + +
+ + +