Implement: Home Page with MUI Components (#13)
Reviewed and merged by Reid (Hermes Reviewer) Co-authored-by: crisleo-hermes <hermes@codescripters.org> Co-committed-by: crisleo-hermes <hermes@codescripters.org>
This commit was merged in pull request #13.
This commit is contained in:
+7
-1
@@ -3,10 +3,16 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
|
||||
/>
|
||||
<title>Job Tracker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+11
-3
@@ -5,15 +5,23 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^7.0.0",
|
||||
"@mui/material": "^7.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Job Tracker</h1>
|
||||
<p>Welcome to the Job Tracker application.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import AppLayout from "./components/AppLayout.tsx";
|
||||
import HomePage from "./pages/HomePage.tsx";
|
||||
|
||||
export default function App(): ReactNode {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Container from "@mui/material/Container";
|
||||
import Box from "@mui/material/Box";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: "#1976d2",
|
||||
},
|
||||
background: {
|
||||
default: "#f5f5f5",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default function AppLayout(): ReactNode {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Box sx={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>
|
||||
<AppBar position="sticky">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
|
||||
Job Tracker
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container component="main" maxWidth="lg" sx={{ mt: 4, mb: 4, flexGrow: 1 }}>
|
||||
<Outlet />
|
||||
</Container>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
|
||||
export default function LoadingSkeleton(): ReactNode {
|
||||
return (
|
||||
<Box>
|
||||
{/* Metrics skeleton row */}
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
{[...Array(4)].map((_, index) => (
|
||||
<Grid key={index} size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Skeleton variant="rounded" height={100} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Button skeleton */}
|
||||
<Skeleton variant="rounded" width={240} height={40} sx={{ mb: 3 }} />
|
||||
|
||||
{/* Update cards skeleton */}
|
||||
<Grid container spacing={3}>
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<Grid key={index} size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<Skeleton variant="rounded" height={180} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import type { DashboardMetrics } from "../types/index.ts";
|
||||
|
||||
interface MetricsPanelProps {
|
||||
metrics: DashboardMetrics;
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, subtitle }: MetricCardProps): ReactNode {
|
||||
return (
|
||||
<Card variant="outlined" sx={{ height: "100%" }}>
|
||||
<CardContent>
|
||||
<Typography variant="h4" component="p" sx={{ fontWeight: 700 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MetricsPanel({ metrics }: MetricsPanelProps): ReactNode {
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<MetricCard title="Total Applications" value={metrics.total_applications} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<MetricCard title="Interviews" value={metrics.interviews_count} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<MetricCard title="Offers" value={metrics.offers_count} />
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<MetricCard
|
||||
title="Rejection Rate"
|
||||
value={`${metrics.rejection_rate}%`}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Card from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { JobUpdate } from "../types/index.ts";
|
||||
|
||||
interface UpdateCardProps {
|
||||
update: JobUpdate;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatStatus(status: string): string {
|
||||
return status.charAt(0) + status.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
const statusColors: Record<string, "default" | "success" | "info" | "warning" | "error"> = {
|
||||
APPLIED: "default",
|
||||
SCREENING: "info",
|
||||
INTERVIEW: "info",
|
||||
OFFER: "success",
|
||||
REJECTED: "error",
|
||||
WITHDRAWN: "default",
|
||||
};
|
||||
|
||||
export default function UpdateCard({ update }: UpdateCardProps): ReactNode {
|
||||
const changeLabel = update.from_status
|
||||
? `${formatStatus(update.from_status)} → ${formatStatus(update.to_status)}`
|
||||
: formatStatus(update.to_status);
|
||||
|
||||
return (
|
||||
<Card variant="outlined" sx={{ height: "100%" }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" component="h2" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
{update.company_name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{update.position_title}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1, mb: 1 }}>
|
||||
<Chip
|
||||
label={changeLabel}
|
||||
size="small"
|
||||
color={statusColors[update.to_status] ?? "default"}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Updated: {formatDate(update.created_at)}
|
||||
</Typography>
|
||||
{update.notes && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{update.notes}
|
||||
</Typography>
|
||||
)}
|
||||
{Object.keys(update.metrics).length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}>
|
||||
Metrics: {JSON.stringify(update.metrics)}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { DashboardData, JobUpdate, DashboardMetrics } from "../types/index.ts";
|
||||
|
||||
interface UseDashboardDataResult {
|
||||
data: DashboardData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function useDashboardData(): UseDashboardDataResult {
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [updates, metrics] = await Promise.all([
|
||||
fetchJson<JobUpdate[]>(`${API_BASE}/updates/latest/`),
|
||||
fetchJson<DashboardMetrics>(`${API_BASE}/updates/metrics/`),
|
||||
]);
|
||||
setData({ updates, metrics });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "An unknown error occurred";
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("Root element not found");
|
||||
|
||||
ReactDOM.createRoot(rootElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Button from "@mui/material/Button";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import UpdateCard from "../components/UpdateCard.tsx";
|
||||
import MetricsPanel from "../components/MetricsPanel.tsx";
|
||||
import LoadingSkeleton from "../components/LoadingSkeleton.tsx";
|
||||
import { useDashboardData } from "../hooks/useDashboardData.ts";
|
||||
|
||||
export default function HomePage(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const { data, loading, error } = useDashboardData();
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" component="h2" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
Dashboard
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
Failed to load dashboard data: {error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<MetricsPanel metrics={data.metrics} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3, display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => navigate("/applications/new")}
|
||||
>
|
||||
Create New Job Application
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" component="h3" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
Recent Updates
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{data.updates.map((update) => (
|
||||
<Grid key={update.id} size={{ xs: 12, sm: 6, md: 4 }}>
|
||||
<UpdateCard update={update} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{data.updates.length === 0 && (
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mt: 2 }}>
|
||||
No updates yet. Create your first job application to get started.
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface JobUpdate {
|
||||
id: number;
|
||||
job_id: number;
|
||||
company_name: string;
|
||||
position_title: string;
|
||||
from_status: string | null;
|
||||
to_status: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
metrics: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DashboardMetrics {
|
||||
total_applications: number;
|
||||
status_breakdown: Record<string, number>;
|
||||
interviews_count: number;
|
||||
offers_count: number;
|
||||
rejection_rate: number;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
updates: JobUpdate[];
|
||||
metrics: DashboardMetrics;
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -9,5 +9,11 @@ export default defineConfig({
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user