AshameTheDestroyer commited on
Commit
3076aac
·
1 Parent(s): 83c4c2d

Create New Job Page.

Browse files
frontend/app/components/sidebar-provider.tsx CHANGED
@@ -38,7 +38,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
38
  key={i}
39
  to={to}
40
  title={title}
41
- className={`flex place-items-center group-data-[collapsible=icon]:place-content-center gap-2 px-4 py-2 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:-mx-2 hover:bg-indigo-200 dark:hover:bg-slate-800 rounded-md ${pathname === to ? "bg-indigo-300 dark:bg-slate-700" : ""}`}
42
  >
43
  <Icon />{" "}
44
  <span className="group-data-[collapsible=icon]:hidden">{title}</span>
 
38
  key={i}
39
  to={to}
40
  title={title}
41
+ className={`flex place-items-center group-data-[collapsible=icon]:place-content-center gap-2 px-4 py-2 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:-mx-2 hover:bg-indigo-200 dark:hover:bg-slate-800 rounded-md ${pathname.startsWith(to) ? "bg-indigo-300 dark:bg-slate-700" : ""}`}
42
  >
43
  <Icon />{" "}
44
  <span className="group-data-[collapsible=icon]:hidden">{title}</span>
frontend/app/routes.ts CHANGED
@@ -3,6 +3,7 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
3
  export default [
4
  index("routes/home.tsx"),
5
  route("jobs", "routes/jobs.tsx"),
 
6
  route("jobs/:id", "routes/jobs.$id.tsx"),
7
  route("jobs/:jid/assessments/:id", "routes/jobs.$jid.assessments.$id.tsx"),
8
  route("jobs/:jid/assessment/:aid/applications", "routes/jobs.$jid.assessment.$aid.applications.tsx"),
 
3
  export default [
4
  index("routes/home.tsx"),
5
  route("jobs", "routes/jobs.tsx"),
6
+ route("jobs/create", "routes/jobs.create.tsx"),
7
  route("jobs/:id", "routes/jobs.$id.tsx"),
8
  route("jobs/:jid/assessments/:id", "routes/jobs.$jid.assessments.$id.tsx"),
9
  route("jobs/:jid/assessment/:aid/applications", "routes/jobs.$jid.assessment.$aid.applications.tsx"),
frontend/app/routes/jobs.$id.tsx CHANGED
@@ -58,8 +58,11 @@ export default function JobDetailRoute() {
58
  <JobCard job={job} isStatic />
59
  <section className="flex flex-col gap-4">
60
  <h3 className="text-xl font-semibold">Job's Assessments</h3>
 
 
 
61
  {assessments?.map(assessment => <AssessmentCard assessment={assessment} jid={job.id} />)}
62
- {total && <Paginator total={total} />}
63
  </section>
64
  </main>
65
  );
 
58
  <JobCard job={job} isStatic />
59
  <section className="flex flex-col gap-4">
60
  <h3 className="text-xl font-semibold">Job's Assessments</h3>
61
+ {assessments.length === 0 && (
62
+ <p className="text-center text-gray-600 dark:text-gray-300">No assessments found for this job.</p>
63
+ )}
64
  {assessments?.map(assessment => <AssessmentCard assessment={assessment} jid={job.id} />)}
65
+ {total != null && total > 0 && <Paginator total={total} />}
66
  </section>
67
  </main>
68
  );
frontend/app/routes/jobs.$jid.assessment.$aid.applications.tsx CHANGED
@@ -86,7 +86,7 @@ export default function AssessmentDetailRoute() {
86
  </div>
87
  ))}
88
  </div>
89
- {total && <Paginator total={total} />}
90
  </section>
91
  </main>
92
  );
 
86
  </div>
87
  ))}
88
  </div>
89
+ {total != null && total > 0 && <Paginator total={total} />}
90
  </section>
91
  </main>
92
  );
frontend/app/routes/jobs.create.tsx ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { XIcon } from "lucide-react";
2
+ import { toast } from "react-toastify";
3
+ import React, { useState } from "react";
4
+ import { useNavigate } from "react-router";
5
+ import { Input } from "~/components/ui/input";
6
+ import { Button } from "~/components/ui/button";
7
+ import type { Route } from "./+types/jobs.create";
8
+ import { Textarea } from "~/components/ui/textarea";
9
+ import { usePostJob, type PostJobPayload } from "~/services/usePostJob";
10
+ import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "~/components/ui/combobox";
11
+
12
+ export function meta({}: Route.MetaArgs) {
13
+ return [
14
+ { title: "Create New Job" },
15
+ {
16
+ name: "description",
17
+ content: "Create a new job listing and find the perfect candidate!",
18
+ },
19
+ ];
20
+ }
21
+
22
+ export default function JobsCreateRoute() {
23
+ const navigate = useNavigate();
24
+ const mutation = usePostJob();
25
+
26
+ const [form, setForm] = useState<PostJobPayload & { skill_categories_raw: string }>({
27
+ title: "",
28
+ description: "",
29
+ seniority: "intern",
30
+ skill_categories: [],
31
+ skill_categories_raw: "",
32
+ } as any);
33
+
34
+ function update<K extends keyof typeof form>(k: K, v: typeof form[K]) {
35
+ setForm((s) => ({ ...s, [k]: v }));
36
+ }
37
+
38
+ async function handleSubmit(e: React.SubmitEvent) {
39
+ e.preventDefault();
40
+
41
+ if (!form.title.trim()) {
42
+ toast.error("Title is required");
43
+ return;
44
+ }
45
+ if (!form.description.trim()) {
46
+ toast.error("Description is required");
47
+ return;
48
+ }
49
+
50
+ const payload: PostJobPayload = {
51
+ title: form.title.trim(),
52
+ description: form.description.trim(),
53
+ seniority: form.seniority,
54
+ skill_categories: form.skill_categories_raw
55
+ .split(",")
56
+ .map((s) => s.trim())
57
+ .filter(Boolean),
58
+ };
59
+
60
+ try {
61
+ await mutation.mutateAsync(payload);
62
+ toast.success("Job created");
63
+ navigate("/jobs");
64
+ } catch (err: any) {
65
+ const message = err?.response?.data?.detail || err?.message || "Failed to create job";
66
+ toast.error(message);
67
+ }
68
+ }
69
+
70
+ return (
71
+ <div className="max-w-3xl mx-auto p-6">
72
+ <h1 className="text-2xl font-semibold mb-4">Create New Job</h1>
73
+
74
+ <form onSubmit={handleSubmit} className="space-y-4 bg-white dark:bg-gray-800 p-6 rounded shadow">
75
+ <div className="flex flex-col gap-2">
76
+ <label className="text-gray-700 dark:text-gray-300">Title</label>
77
+ <Input value={form.title} onChange={(e) => update("title", e.target.value)} placeholder="Senior Frontend Engineer" />
78
+ </div>
79
+
80
+ <div className="flex flex-col gap-2">
81
+ <label className="text-gray-700 dark:text-gray-300">Description</label>
82
+ <Textarea value={form.description} onChange={(e) => update("description", e.target.value)} placeholder="Describe the role, responsibilities and expectations" />
83
+ </div>
84
+
85
+ <div className="flex flex-col gap-2">
86
+ <label className="text-gray-700 dark:text-gray-300">Seniority</label>
87
+ <Combobox items={["intern", "junior", "mid", "senior"]} value={form.seniority} onValueChange={(value) => update("seniority", value as any)}>
88
+ <ComboboxInput placeholder="Choose value" />
89
+ <ComboboxContent>
90
+ <ComboboxEmpty>No items found.</ComboboxEmpty>
91
+ <ComboboxList>
92
+ {(item) => (
93
+ <ComboboxItem key={item} value={item}>
94
+ {item}
95
+ </ComboboxItem>
96
+ )}
97
+ </ComboboxList>
98
+ </ComboboxContent>
99
+ </Combobox>
100
+ </div>
101
+
102
+ <div className="flex flex-col gap-2">
103
+ <label className="text-gray-700 dark:text-gray-300">Skill categories (comma separated)</label>
104
+ <div className="flex flex-wrap gap-2">
105
+ {form.skill_categories.map((skill) => (
106
+ <span
107
+ key={skill}
108
+ className="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-sm rounded-full"
109
+ >
110
+ <span>{skill}</span>
111
+ <Button
112
+ type="button"
113
+ variant="ghost"
114
+ className="text-xs h-6 w-6 text-gray-600 dark:text-gray-300 hover:text-red-500 dark:hover:text-red-500"
115
+ onClick={() => {
116
+ const next = form.skill_categories.filter((s) => s !== skill);
117
+ update("skill_categories", next as any);
118
+ update("skill_categories_raw", next.join(", ") as any);
119
+ }}
120
+ >
121
+ <XIcon className="size-4" />
122
+ </Button>
123
+ </span>
124
+ ))}
125
+ </div>
126
+ <SkillInput form={form} update={update} />
127
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Enter skills separated by commas; they will be saved as an array.</p>
128
+ </div>
129
+
130
+ <div className="flex items-center gap-3 place-content-end">
131
+ <Button variant="outline" type="button" onClick={() => {
132
+ setForm({ title: "", description: "", seniority: "junior", skill_categories: [], skill_categories_raw: "" } as any);
133
+ }}>
134
+ Clear
135
+ </Button>
136
+ <Button type="submit" disabled={mutation.isPending}>
137
+ {mutation.isPending ? "Creating…" : "Create Job"}
138
+ </Button>
139
+ </div>
140
+
141
+ {mutation.isError && <div className="text-sm text-red-500">Error: {(mutation.error as any)?.message ?? "An error occurred"}</div>}
142
+ </form>
143
+ </div>
144
+ );
145
+ }
146
+
147
+ function SkillInput({ form, update } : { form: PostJobPayload & { skill_categories_raw: string; }, update: <K extends keyof PostJobPayload | "skill_categories_raw">(k: K, v: (PostJobPayload & { skill_categories_raw: string; })[K]) => void }) {
148
+ const [skillInput, setSkillInput] = useState("");
149
+
150
+ function addFromInput(raw: string) {
151
+ const parts = raw
152
+ .split(",")
153
+ .map((s) => s.trim())
154
+ .filter(Boolean);
155
+ if (parts.length === 0) return;
156
+ const next = Array.from(new Set([...form.skill_categories, ...parts]));
157
+ update("skill_categories", next as any);
158
+ update("skill_categories_raw", next.join(", ") as any);
159
+ setSkillInput("");
160
+ }
161
+
162
+ return (
163
+ <Input
164
+ className="w-full border rounded px-3 py-2 bg-white dark:bg-gray-800"
165
+ placeholder="Add a skill and press Enter (or type comma)"
166
+ value={skillInput}
167
+ onChange={(e) => setSkillInput(e.target.value)}
168
+ onKeyDown={(e) => {
169
+ if (e.key === "Enter" || e.key === ",") {
170
+ e.preventDefault();
171
+ addFromInput(skillInput);
172
+ } else if (e.key === "Backspace" && !skillInput && form.skill_categories.length) {
173
+ const next = form.skill_categories.slice(0, -1);
174
+ update("skill_categories", next as any);
175
+ update("skill_categories_raw", next.join(", ") as any);
176
+ }
177
+ } }
178
+ onBlur={() => {
179
+ if (skillInput.trim()) addFromInput(skillInput);
180
+ } } />
181
+ );
182
+ }
frontend/app/routes/jobs.tsx CHANGED
@@ -1,8 +1,10 @@
1
- import { Loader2Icon } from "lucide-react";
2
  import type { Route } from "./+types/jobs";
 
3
  import { JobCard } from "~/components/job-card";
 
4
  import { useGetJobs } from "~/services/useGetJobs";
5
  import { Paginator } from "~/components/paginator";
 
6
 
7
  export function meta({}: Route.MetaArgs) {
8
  return [
@@ -16,12 +18,19 @@ export function meta({}: Route.MetaArgs) {
16
 
17
  export default function JobsRoute() {
18
  const { data: { data: jobs, total } = { data: [] }, isLoading, isError, refetch } = useGetJobs();
 
19
 
20
  return (
21
  <main className="container mx-auto p-4 flex flex-col gap-4">
22
  <header className="flex place-content-between gap-4 flex-wrap">
23
  <h1 className="font-bold text-4xl">Active Jobs</h1>
24
- <p>{total} jobs in total</p>
 
 
 
 
 
 
25
  </header>
26
 
27
  <section className="grid grid-cols-[repeat(auto-fill,minmax(400px,1fr))] gap-4">
@@ -43,7 +52,7 @@ export default function JobsRoute() {
43
  </div>
44
  )}
45
  {jobs?.map(job => <JobCard key={job.id} job={job} />)}
46
- {total && <Paginator total={total} />}
47
  </section>
48
  </main>
49
  );
 
 
1
  import type { Route } from "./+types/jobs";
2
+ import { useNavigate } from "react-router";
3
  import { JobCard } from "~/components/job-card";
4
+ import { Button } from "~/components/ui/button";
5
  import { useGetJobs } from "~/services/useGetJobs";
6
  import { Paginator } from "~/components/paginator";
7
+ import { Loader2Icon, PlusIcon } from "lucide-react";
8
 
9
  export function meta({}: Route.MetaArgs) {
10
  return [
 
18
 
19
  export default function JobsRoute() {
20
  const { data: { data: jobs, total } = { data: [] }, isLoading, isError, refetch } = useGetJobs();
21
+ const Navigate = useNavigate();
22
 
23
  return (
24
  <main className="container mx-auto p-4 flex flex-col gap-4">
25
  <header className="flex place-content-between gap-4 flex-wrap">
26
  <h1 className="font-bold text-4xl">Active Jobs</h1>
27
+ <div className="flex flex-col gap-2 place-content-center">
28
+ <Button className="mb-2 sm:mb-0" onClick={() => Navigate("/jobs/create")}>
29
+ <PlusIcon />
30
+ Create New Job
31
+ </Button>
32
+ <p className="text-center">{total} jobs in total</p>
33
+ </div>
34
  </header>
35
 
36
  <section className="grid grid-cols-[repeat(auto-fill,minmax(400px,1fr))] gap-4">
 
52
  </div>
53
  )}
54
  {jobs?.map(job => <JobCard key={job.id} job={job} />)}
55
+ {total != null && total > 0 && <Paginator total={total} />}
56
  </section>
57
  </main>
58
  );
frontend/app/services/usePostJob.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMutation } from "@tanstack/react-query";
2
+ import { HTTPManager } from "~/managers/HTTPManager";
3
+
4
+ export const POST_JOB_KEY = "post-job"
5
+
6
+ export type PostJobPayload = {
7
+ title: string;
8
+ description: string;
9
+ seniority: "internship" | "junior" | "mid" | "senior";
10
+ skill_categories: Array<string>;
11
+ };
12
+
13
+ export const usePostJob = () => useMutation({
14
+ mutationKey: [POST_JOB_KEY],
15
+ mutationFn: async (payload: PostJobPayload) =>
16
+ HTTPManager.post("/jobs", payload).then(response => response.data),
17
+ });