Commit ·
a774a10
1
Parent(s): 7a6bc15
Dashboard Initiated.
Browse files- frontend/app/components/overview-charts.tsx +72 -0
- frontend/app/routes.ts +1 -0
- frontend/app/routes/dashboard.tsx +90 -0
- frontend/package-lock.json +30 -0
- frontend/package.json +2 -0
frontend/app/components/overview-charts.tsx
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Legend,
|
| 3 |
+
Tooltip,
|
| 4 |
+
BarElement,
|
| 5 |
+
ArcElement,
|
| 6 |
+
LinearScale,
|
| 7 |
+
LineElement,
|
| 8 |
+
PointElement,
|
| 9 |
+
CategoryScale,
|
| 10 |
+
Chart as ChartJS,
|
| 11 |
+
} from "chart.js";
|
| 12 |
+
import { Bar, Doughnut } from "react-chartjs-2";
|
| 13 |
+
import type { Job } from "~/services/useGetJobs";
|
| 14 |
+
|
| 15 |
+
ChartJS.register(
|
| 16 |
+
CategoryScale,
|
| 17 |
+
LinearScale,
|
| 18 |
+
BarElement,
|
| 19 |
+
PointElement,
|
| 20 |
+
LineElement,
|
| 21 |
+
Tooltip,
|
| 22 |
+
Legend,
|
| 23 |
+
ArcElement
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
export default function OverviewCharts({ jobs, loading }: { jobs: Job[]; loading?: boolean }) {
|
| 27 |
+
const labels = jobs.map(j => (j.title.length > 20 ? j.title.slice(0, 20) + "…" : j.title));
|
| 28 |
+
const applicantsData = jobs.map(j => j.applicants_count ?? 0);
|
| 29 |
+
|
| 30 |
+
const seniorities = ["intern", "junior", "mid", "senior"];
|
| 31 |
+
const seniorityCounts = seniorities.map(s => jobs.filter(j => j.seniority === s).length);
|
| 32 |
+
|
| 33 |
+
const barData = {
|
| 34 |
+
labels,
|
| 35 |
+
datasets: [
|
| 36 |
+
{
|
| 37 |
+
label: "Applicants",
|
| 38 |
+
data: applicantsData,
|
| 39 |
+
backgroundColor: "rgba(34,197,94,0.8)",
|
| 40 |
+
},
|
| 41 |
+
],
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
const doughnutData = {
|
| 45 |
+
labels: seniorities,
|
| 46 |
+
datasets: [
|
| 47 |
+
{
|
| 48 |
+
data: seniorityCounts,
|
| 49 |
+
backgroundColor: ["#60A5FA", "#F59E0B", "#A78BFA", "#FB7185"],
|
| 50 |
+
},
|
| 51 |
+
],
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
if (loading) {
|
| 55 |
+
return <div className="h-64 flex items-center justify-center text-gray-500">Loading charts…</div>;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if (jobs.length === 0) {
|
| 59 |
+
return <div className="h-64 flex items-center justify-center text-gray-500">No data to display.</div>;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 64 |
+
<div className="h-64">
|
| 65 |
+
<Bar data={barData} options={{ responsive: true, maintainAspectRatio: false }} />
|
| 66 |
+
</div>
|
| 67 |
+
<div className="h-64">
|
| 68 |
+
<Doughnut data={doughnutData} options={{ responsive: true, maintainAspectRatio: false }} />
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
);
|
| 72 |
+
}
|
frontend/app/routes.ts
CHANGED
|
@@ -5,4 +5,5 @@ export default [
|
|
| 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 |
] satisfies RouteConfig;
|
|
|
|
| 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("dashboard", "routes/dashboard.tsx"),
|
| 9 |
] satisfies RouteConfig;
|
frontend/app/routes/dashboard.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo } from "react";
|
| 2 |
+
import type { Route } from "./+types/dashboard";
|
| 3 |
+
import { useGetJobs } from "~/services/useGetJobs";
|
| 4 |
+
import OverviewCharts from "~/components/overview-charts";
|
| 5 |
+
|
| 6 |
+
export function meta({}: Route.MetaArgs) {
|
| 7 |
+
return [
|
| 8 |
+
{ title: "Dashboard" },
|
| 9 |
+
{
|
| 10 |
+
name: "description",
|
| 11 |
+
content: "Overview of platform metrics and insights to track your hiring process.",
|
| 12 |
+
},
|
| 13 |
+
];
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function Dashboard() {
|
| 17 |
+
const { data, isLoading, isError } = useGetJobs();
|
| 18 |
+
const jobs = data?.data ?? [];
|
| 19 |
+
|
| 20 |
+
const totals = useMemo(() => {
|
| 21 |
+
const totalJobs = jobs.length;
|
| 22 |
+
const totalApplicants = jobs.reduce((s, j) => s + (j.applicants_count ?? 0), 0);
|
| 23 |
+
const activeJobs = jobs.filter(j => j.active).length;
|
| 24 |
+
return { totalJobs, totalApplicants, activeJobs };
|
| 25 |
+
}, [jobs]);
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div className="p-6 space-y-6">
|
| 29 |
+
<header className="flex items-center justify-between">
|
| 30 |
+
<h1 className="text-2xl font-semibold">Dashboard</h1>
|
| 31 |
+
<p className="text-sm text-muted-foreground">Overview and quick insights</p>
|
| 32 |
+
</header>
|
| 33 |
+
|
| 34 |
+
<section className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
| 35 |
+
<div className="p-4 bg-white dark:bg-gray-800 shadow rounded">
|
| 36 |
+
<div className="text-sm text-gray-500 dark:text-gray-200">Total Jobs</div>
|
| 37 |
+
<div className="mt-2 text-3xl font-bold">{isLoading ? "…" : totals.totalJobs}</div>
|
| 38 |
+
</div>
|
| 39 |
+
<div className="p-4 bg-white dark:bg-gray-800 shadow rounded">
|
| 40 |
+
<div className="text-sm text-gray-500 dark:text-gray-200">Total Applicants</div>
|
| 41 |
+
<div className="mt-2 text-3xl font-bold">{isLoading ? "…" : totals.totalApplicants}</div>
|
| 42 |
+
</div>
|
| 43 |
+
<div className="p-4 bg-white dark:bg-gray-800 shadow rounded">
|
| 44 |
+
<div className="text-sm text-gray-500 dark:text-gray-200">Active Jobs</div>
|
| 45 |
+
<div className="mt-2 text-3xl font-bold">{isLoading ? "…" : totals.activeJobs}</div>
|
| 46 |
+
</div>
|
| 47 |
+
</section>
|
| 48 |
+
|
| 49 |
+
<section className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
| 50 |
+
<div className="p-4 bg-white dark:bg-gray-800 shadow rounded">
|
| 51 |
+
<h2 className="text-lg font-medium mb-4">Applicants Per Job</h2>
|
| 52 |
+
<OverviewCharts jobs={jobs} loading={isLoading} />
|
| 53 |
+
</div>
|
| 54 |
+
<div className="p-4 bg-white dark:bg-gray-800 shadow rounded">
|
| 55 |
+
<h2 className="text-lg font-medium mb-4">Quick Insights</h2>
|
| 56 |
+
{isError && <div className="text-red-500">Error loading data</div>}
|
| 57 |
+
{!isLoading && jobs.length === 0 && <div className="text-sm text-gray-600 dark:text-gray-200">No jobs yet.</div>}
|
| 58 |
+
{!isLoading && jobs.length > 0 && (
|
| 59 |
+
<ul className="space-y-3">
|
| 60 |
+
<li className="flex justify-between">
|
| 61 |
+
<span className="text-sm text-gray-700 dark:text-gray-200">Top job (by applicants)</span>
|
| 62 |
+
<span className="font-medium">
|
| 63 |
+
{jobs.slice().sort((a,b) => (b.applicants_count||0)-(a.applicants_count||0))[0]?.title ?? "—"}
|
| 64 |
+
</span>
|
| 65 |
+
</li>
|
| 66 |
+
<li className="flex justify-between">
|
| 67 |
+
<span className="text-sm text-gray-700 dark:text-gray-200">Most common seniority</span>
|
| 68 |
+
<span className="font-medium">
|
| 69 |
+
{(() => {
|
| 70 |
+
const counts = jobs.reduce<Record<string,number>>((acc, j) => {
|
| 71 |
+
acc[j.seniority] = (acc[j.seniority] || 0) + 1;
|
| 72 |
+
return acc;
|
| 73 |
+
}, {});
|
| 74 |
+
const entries = Object.entries(counts);
|
| 75 |
+
if (!entries.length) return "—";
|
| 76 |
+
return entries.sort((a,b) => b[1]-a[1])[0][0];
|
| 77 |
+
})()}
|
| 78 |
+
</span>
|
| 79 |
+
</li>
|
| 80 |
+
<li className="flex justify-between">
|
| 81 |
+
<span className="text-sm text-gray-700 dark:text-gray-200">Jobs with no applicants</span>
|
| 82 |
+
<span className="font-medium">{jobs.filter(j => !j.applicants_count).length}</span>
|
| 83 |
+
</li>
|
| 84 |
+
</ul>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
</section>
|
| 88 |
+
</div>
|
| 89 |
+
);
|
| 90 |
+
}
|
frontend/package-lock.json
CHANGED
|
@@ -11,12 +11,14 @@
|
|
| 11 |
"@react-router/serve": "7.12.0",
|
| 12 |
"@tanstack/react-query": "^4.43.0",
|
| 13 |
"axios": "^1.13.4",
|
|
|
|
| 14 |
"class-variance-authority": "^0.7.1",
|
| 15 |
"clsx": "^2.1.1",
|
| 16 |
"isbot": "^5.1.31",
|
| 17 |
"lucide-react": "^0.563.0",
|
| 18 |
"radix-ui": "^1.4.3",
|
| 19 |
"react": "^19.2.4",
|
|
|
|
| 20 |
"react-dom": "^19.2.4",
|
| 21 |
"react-router": "7.12.0",
|
| 22 |
"react-toastify": "^11.0.5",
|
|
@@ -1091,6 +1093,12 @@
|
|
| 1091 |
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 1092 |
}
|
| 1093 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1094 |
"node_modules/@mjackson/node-fetch-server": {
|
| 1095 |
"version": "0.2.0",
|
| 1096 |
"resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz",
|
|
@@ -3688,6 +3696,18 @@
|
|
| 3688 |
],
|
| 3689 |
"license": "CC-BY-4.0"
|
| 3690 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3691 |
"node_modules/chokidar": {
|
| 3692 |
"version": "4.0.3",
|
| 3693 |
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
|
@@ -5282,6 +5302,16 @@
|
|
| 5282 |
"node": ">=0.10.0"
|
| 5283 |
}
|
| 5284 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5285 |
"node_modules/react-dom": {
|
| 5286 |
"version": "19.2.4",
|
| 5287 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
|
|
|
| 11 |
"@react-router/serve": "7.12.0",
|
| 12 |
"@tanstack/react-query": "^4.43.0",
|
| 13 |
"axios": "^1.13.4",
|
| 14 |
+
"chart.js": "^4.5.1",
|
| 15 |
"class-variance-authority": "^0.7.1",
|
| 16 |
"clsx": "^2.1.1",
|
| 17 |
"isbot": "^5.1.31",
|
| 18 |
"lucide-react": "^0.563.0",
|
| 19 |
"radix-ui": "^1.4.3",
|
| 20 |
"react": "^19.2.4",
|
| 21 |
+
"react-chartjs-2": "^5.3.1",
|
| 22 |
"react-dom": "^19.2.4",
|
| 23 |
"react-router": "7.12.0",
|
| 24 |
"react-toastify": "^11.0.5",
|
|
|
|
| 1093 |
"@jridgewell/sourcemap-codec": "^1.4.14"
|
| 1094 |
}
|
| 1095 |
},
|
| 1096 |
+
"node_modules/@kurkle/color": {
|
| 1097 |
+
"version": "0.3.4",
|
| 1098 |
+
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
| 1099 |
+
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
| 1100 |
+
"license": "MIT"
|
| 1101 |
+
},
|
| 1102 |
"node_modules/@mjackson/node-fetch-server": {
|
| 1103 |
"version": "0.2.0",
|
| 1104 |
"resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz",
|
|
|
|
| 3696 |
],
|
| 3697 |
"license": "CC-BY-4.0"
|
| 3698 |
},
|
| 3699 |
+
"node_modules/chart.js": {
|
| 3700 |
+
"version": "4.5.1",
|
| 3701 |
+
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
| 3702 |
+
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
| 3703 |
+
"license": "MIT",
|
| 3704 |
+
"dependencies": {
|
| 3705 |
+
"@kurkle/color": "^0.3.0"
|
| 3706 |
+
},
|
| 3707 |
+
"engines": {
|
| 3708 |
+
"pnpm": ">=8"
|
| 3709 |
+
}
|
| 3710 |
+
},
|
| 3711 |
"node_modules/chokidar": {
|
| 3712 |
"version": "4.0.3",
|
| 3713 |
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
|
|
|
| 5302 |
"node": ">=0.10.0"
|
| 5303 |
}
|
| 5304 |
},
|
| 5305 |
+
"node_modules/react-chartjs-2": {
|
| 5306 |
+
"version": "5.3.1",
|
| 5307 |
+
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
|
| 5308 |
+
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
|
| 5309 |
+
"license": "MIT",
|
| 5310 |
+
"peerDependencies": {
|
| 5311 |
+
"chart.js": "^4.1.1",
|
| 5312 |
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 5313 |
+
}
|
| 5314 |
+
},
|
| 5315 |
"node_modules/react-dom": {
|
| 5316 |
"version": "19.2.4",
|
| 5317 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
frontend/package.json
CHANGED
|
@@ -14,12 +14,14 @@
|
|
| 14 |
"@react-router/serve": "7.12.0",
|
| 15 |
"@tanstack/react-query": "^4.43.0",
|
| 16 |
"axios": "^1.13.4",
|
|
|
|
| 17 |
"class-variance-authority": "^0.7.1",
|
| 18 |
"clsx": "^2.1.1",
|
| 19 |
"isbot": "^5.1.31",
|
| 20 |
"lucide-react": "^0.563.0",
|
| 21 |
"radix-ui": "^1.4.3",
|
| 22 |
"react": "^19.2.4",
|
|
|
|
| 23 |
"react-dom": "^19.2.4",
|
| 24 |
"react-router": "7.12.0",
|
| 25 |
"react-toastify": "^11.0.5",
|
|
|
|
| 14 |
"@react-router/serve": "7.12.0",
|
| 15 |
"@tanstack/react-query": "^4.43.0",
|
| 16 |
"axios": "^1.13.4",
|
| 17 |
+
"chart.js": "^4.5.1",
|
| 18 |
"class-variance-authority": "^0.7.1",
|
| 19 |
"clsx": "^2.1.1",
|
| 20 |
"isbot": "^5.1.31",
|
| 21 |
"lucide-react": "^0.563.0",
|
| 22 |
"radix-ui": "^1.4.3",
|
| 23 |
"react": "^19.2.4",
|
| 24 |
+
"react-chartjs-2": "^5.3.1",
|
| 25 |
"react-dom": "^19.2.4",
|
| 26 |
"react-router": "7.12.0",
|
| 27 |
"react-toastify": "^11.0.5",
|