Author SHA1 Message Date
Marko (Hermes Implementer) 48db53c95d Merge branch 'main' into feature/user-auth
Resolved merge conflicts integrating PR #13 (Home Page with MUI v2):

Backend changes:
- settings.py: Combined INSTALLED_APPS (accounts + jobs + corsheaders),
  kept HEAD's REST_FRAMEWORK (AllowAny + throttling) and SIMPLE_JWT,
  added origin/main's CORS config
- urls.py: Combined admin/, api/auth/, api/ routes
- pyproject.toml: Combined all dependencies (simplejwt + cors-headers)
- uv.lock: Regenerated with updated dependencies

Frontend changes:
- package.json: Combined all deps (axios + MUI + emotion)
- App.tsx: Integrated AuthProvider with MUI AppLayout, all routes
- HomePage.tsx: Show landing view when unauthenticated, MUI dashboard
  when authenticated
- main.tsx, tsconfig.json, vite-env.d.ts: Combined both versions
- yarn.lock: Kept origin/main's version (regenerated on install)
2026-05-26 19:07:02 +00:00
Marko (Hermes Implementer) 9846f823b3 fix: migrate inline styles to CSS modules per review feedback 2026-05-26 17:04:58 +00:00
markoandreid 5b6f628380 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>
2026-05-26 06:21:33 +00:00
Marko (Hermes Implementer) e6e6c92c28 fix: review issues - add rate limiting for auth views, add __str__ to User model 2026-05-26 06:07:58 +00:00
36 changed files with 2472 additions and 353 deletions
+8 -1
View File
@@ -1,16 +1,22 @@
from typing import Any
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.throttling import AnonRateThrottle
from accounts.serializers import LoginSerializer, RegisterSerializer, UserSerializer
class AuthRateThrottle(AnonRateThrottle):
scope = "auth"
@api_view(["POST"])
@permission_classes([AllowAny])
@throttle_classes([AuthRateThrottle])
def register_view(request: Request) -> Response:
"""Register a new user account."""
serializer = RegisterSerializer(data=request.data)
@@ -24,6 +30,7 @@ def register_view(request: Request) -> Response:
@api_view(["POST"])
@permission_classes([AllowAny])
@throttle_classes([AuthRateThrottle])
def login_view(request: Request) -> Response:
"""Authenticate a user and return JWT tokens."""
serializer = LoginSerializer(
View File
+17
View File
@@ -0,0 +1,17 @@
from django.contrib import admin
from .models import JobApplication, JobUpdate
@admin.register(JobApplication)
class JobApplicationAdmin(admin.ModelAdmin):
list_display = ["company_name", "position_title", "status", "created_at", "updated_at"]
list_filter = ["status"]
search_fields = ["company_name", "position_title"]
@admin.register(JobUpdate)
class JobUpdateAdmin(admin.ModelAdmin):
list_display = ["job_application", "from_status", "to_status", "created_at"]
list_filter = ["to_status"]
search_fields = ["job_application__company_name"]
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class JobsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'jobs'
+47
View File
@@ -0,0 +1,47 @@
# Generated by Django 5.2.14 on 2026-05-26 00:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='JobApplication',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('company_name', models.CharField(max_length=255)),
('position_title', models.CharField(max_length=255)),
('status', models.CharField(choices=[('APPLIED', 'Applied'), ('SCREENING', 'Screening'), ('INTERVIEW', 'Interview'), ('OFFER', 'Offer'), ('REJECTED', 'Rejected'), ('WITHDRAWN', 'Withdrawn')], default='APPLIED', max_length=20)),
('notes', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='JobUpdate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('from_status', models.CharField(blank=True, choices=[('APPLIED', 'Applied'), ('SCREENING', 'Screening'), ('INTERVIEW', 'Interview'), ('OFFER', 'Offer'), ('REJECTED', 'Rejected'), ('WITHDRAWN', 'Withdrawn')], max_length=20, null=True)),
('to_status', models.CharField(choices=[('APPLIED', 'Applied'), ('SCREENING', 'Screening'), ('INTERVIEW', 'Interview'), ('OFFER', 'Offer'), ('REJECTED', 'Rejected'), ('WITHDRAWN', 'Withdrawn')], max_length=20)),
('notes', models.TextField(blank=True, default='')),
('created_at', models.DateTimeField(auto_now_add=True)),
('metrics', models.JSONField(blank=True, default=dict)),
('job_application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='jobs.jobapplication')),
],
options={
'verbose_name': 'Job Update',
'verbose_name_plural': 'Job Updates',
'ordering': ['-created_at'],
},
),
]
View File
+60
View File
@@ -0,0 +1,60 @@
from django.db import models
class StatusChoices(models.TextChoices):
APPLIED = "APPLIED", "Applied"
SCREENING = "SCREENING", "Screening"
INTERVIEW = "INTERVIEW", "Interview"
OFFER = "OFFER", "Offer"
REJECTED = "REJECTED", "Rejected"
WITHDRAWN = "WITHDRAWN", "Withdrawn"
class JobApplication(models.Model):
company_name = models.CharField(max_length=255)
position_title = models.CharField(max_length=255)
status = models.CharField(
max_length=20,
choices=StatusChoices.choices,
default=StatusChoices.APPLIED,
)
notes = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-updated_at"]
def __str__(self) -> str:
return f"{self.company_name} - {self.position_title}"
class JobUpdate(models.Model):
job_application = models.ForeignKey(
JobApplication,
on_delete=models.CASCADE,
related_name="updates",
)
from_status = models.CharField(
max_length=20,
choices=StatusChoices.choices,
null=True,
blank=True,
)
to_status = models.CharField(
max_length=20,
choices=StatusChoices.choices,
)
notes = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
# Store metrics as JSON at update time for historical accuracy
metrics = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-created_at"]
verbose_name = "Job Update"
verbose_name_plural = "Job Updates"
def __str__(self) -> str:
return f"Update #{self.id}: {self.job_application.company_name}{self.to_status}"
+45
View File
@@ -0,0 +1,45 @@
from rest_framework import serializers
from .models import JobApplication, JobUpdate
class JobUpdateSerializer(serializers.ModelSerializer):
company_name = serializers.CharField(source="job_application.company_name", read_only=True)
position_title = serializers.CharField(source="job_application.position_title", read_only=True)
job_id = serializers.IntegerField(source="job_application.id", read_only=True)
class Meta:
model = JobUpdate
fields = [
"id",
"job_id",
"company_name",
"position_title",
"from_status",
"to_status",
"notes",
"created_at",
"metrics",
]
class JobApplicationSerializer(serializers.ModelSerializer):
class Meta:
model = JobApplication
fields = [
"id",
"company_name",
"position_title",
"status",
"notes",
"created_at",
"updated_at",
]
class DashboardMetricsSerializer(serializers.Serializer):
total_applications = serializers.IntegerField()
status_breakdown = serializers.DictField(child=serializers.IntegerField())
interviews_count = serializers.IntegerField()
offers_count = serializers.IntegerField()
rejection_rate = serializers.FloatField()
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+12
View File
@@ -0,0 +1,12 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r"applications", views.JobApplicationViewSet, basename="job-application")
router.register(r"updates", views.JobUpdateViewSet, basename="job-update")
urlpatterns = [
path("", include(router.urls)),
]
+68
View File
@@ -0,0 +1,68 @@
from django.db.models import Count, Q
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from .models import JobApplication, JobUpdate
from .serializers import (
DashboardMetricsSerializer,
JobApplicationSerializer,
JobUpdateSerializer,
)
class JobApplicationViewSet(viewsets.ModelViewSet):
queryset = JobApplication.objects.all().prefetch_related("updates")
serializer_class = JobApplicationSerializer
permission_classes = [IsAuthenticated]
class JobUpdateViewSet(viewsets.ModelViewSet):
queryset = JobUpdate.objects.select_related("job_application").all()
serializer_class = JobUpdateSerializer
permission_classes = [IsAuthenticated]
@action(detail=False, methods=["get"])
def latest(self, request: Request) -> Response:
"""Return the 3 most recent job updates with job info and metrics."""
updates = (
self.get_queryset()
.select_related("job_application")
.order_by("-created_at")[:3]
)
serializer = self.get_serializer(updates, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"])
def metrics(self, request: Request) -> Response:
"""Return dashboard metrics: total apps, status breakdown, interview/offer counts."""
total = JobApplication.objects.count()
status_counts = dict(
JobApplication.objects.values("status")
.annotate(count=Count("id"))
.values_list("status", "count")
)
interview_count = JobApplication.objects.filter(
Q(status="INTERVIEW") | Q(status="OFFER")
).count()
offer_count = JobApplication.objects.filter(status="OFFER").count()
rejection_rate = (
round(
status_counts.get("REJECTED", 0) / total * 100, 1
)
if total > 0
else 0.0
)
serializer = DashboardMetricsSerializer(
instance={
"total_applications": total,
"status_breakdown": status_counts,
"interviews_count": interview_count,
"offers_count": offer_count,
"rejection_rate": rejection_rate,
}
)
return Response(serializer.data)
+22 -3
View File
@@ -14,17 +14,22 @@ DEBUG = os.environ.get("DJANGO_DEBUG", "True").lower() in ("true", "1", "yes")
ALLOWED_HOSTS: list[str] = ["*"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.auth",
"django.contrib.admin",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Third-party
"rest_framework",
"corsheaders",
# Local
"accounts",
"jobs",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
@@ -91,6 +96,13 @@ REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": (
"rest_framework.renderers.JSONRenderer",
),
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": os.environ.get("DJANGO_THROTTLE_ANON_RATE", "10/hour"),
"auth": os.environ.get("DJANGO_THROTTLE_AUTH_RATE", "5/minute"),
},
}
SIMPLE_JWT = {
@@ -99,11 +111,18 @@ SIMPLE_JWT = {
"AUTH_HEADER_TYPES": ("Bearer",),
}
# CORS
CORS_ALLOW_ALL_ORIGINS = os.environ.get("CORS_ALLOW_ALL_ORIGINS", "False").lower() in ("true", "1", "yes")
CORS_ALLOWED_ORIGINS = os.environ.get(
"CORS_ALLOWED_ORIGINS",
"http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000",
).split(",")
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_URL = "/static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+4 -2
View File
@@ -1,6 +1,8 @@
from django.contrib import admin
from django.urls import include, path
urlpatterns: list = [
path("api/auth/", include("accounts.urls", namespace="accounts")),
urlpatterns = [
path("admin/", admin.site.urls),
path("api/auth/", include("accounts.urls")),
path("api/", include("jobs.urls")),
]
+3 -2
View File
@@ -5,7 +5,8 @@ description = "Job Tracker API"
requires-python = ">=3.12"
dependencies = [
"django>=5.1,<6.0",
"djangorestframework>=3.15,<4.0",
"django-cors-headers>=4.9.0",
"djangorestframework>=3.17.1",
"djangorestframework-simplejwt>=5.3,<6.0",
"psycopg2-binary>=2.9",
"pydantic>=2.0",
@@ -17,4 +18,4 @@ requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["project*", "accounts*"]
include = ["project*", "accounts*", "jobs*"]
Generated
+16 -1
View File
@@ -17,6 +17,7 @@ version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "django" },
{ name = "django-cors-headers" },
{ name = "djangorestframework" },
{ name = "djangorestframework-simplejwt" },
{ name = "psycopg2-binary" },
@@ -27,7 +28,8 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "django", specifier = ">=5.1,<6.0" },
{ name = "djangorestframework", specifier = ">=3.15,<4.0" },
{ name = "django-cors-headers", specifier = ">=4.9.0" },
{ name = "djangorestframework", specifier = ">=3.17.1" },
{ name = "djangorestframework-simplejwt", specifier = ">=5.3,<6.0" },
{ name = "psycopg2-binary", specifier = ">=2.9" },
{ name = "pydantic", specifier = ">=2.0" },
@@ -57,6 +59,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
]
[[package]]
name = "django-cors-headers"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
]
[[package]]
name = "djangorestframework"
version = "3.17.1"
+115
View File
@@ -0,0 +1,115 @@
# Spec: Home Page with MUI Components
## Ticket
Gitea: crisleo-hermes/job-tracker#2
Kanban: t_e6936299
## Overview
Build a home page at route `/` using Material-UI (MUI) components that displays the last three job updates and dashboard metrics, with a button to create a new job application.
## Pages
### HomePage (`/`)
- AppBar with title "Job Tracker"
- Dashboard metrics cards row (total applications, interviews, offers, rejection rate)
- Last 3 job updates displayed as cards
- "Create New Job Application" button (navigates to creation flow)
- Loading skeleton while data fetches
- Error snackbar on API failure
- Responsive Grid layout
## API Endpoints Consumed
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/updates/latest/` | 3 most recent JobUpdates with nested job info |
| GET | `/api/updates/metrics/` | Dashboard metrics (total, status breakdown, interviews, offers, rejection rate) |
### Response Shapes
**GET /api/updates/latest/**
```json
[
{
"id": 1,
"job_id": 1,
"company_name": "Acme Corp",
"position_title": "Software Engineer",
"from_status": "APPLIED",
"to_status": "INTERVIEW",
"notes": "Moving to next round",
"created_at": "2025-01-15T10:00:00Z",
"metrics": {"response_time_days": 5}
}
]
```
**GET /api/updates/metrics/**
```json
{
"total_applications": 12,
"status_breakdown": {"APPLIED": 5, "INTERVIEW": 4, "OFFER": 2, "REJECTED": 1},
"interviews_count": 6,
"offers_count": 2,
"rejection_rate": 8.3
}
```
## Components
### `AppLayout`
- MUI `AppBar` with `Toolbar`, `Typography` ("Job Tracker")
- Wraps child content via `Outlet` from React Router
### `UpdateCard`
- MUI `Card``CardContent`
- Displays: company name, position title, status change (`from_status``to_status`), created date, metrics summary
- Props: `JobUpdate`
### `MetricsPanel`
- MUI `Grid` container with metric `Card` items
- Each metric: total applications, interviews count, offers count, rejection rate
- Props: `DashboardMetrics`
### `LoadingSkeleton`
- MUI `Skeleton` components mimicking the home page layout
## Data Fetching
Custom hook `useDashboardData` using React's `useEffect` + `useState`:
- Fetches from `/api/updates/latest/` and `/api/updates/metrics/` concurrently (`Promise.all`)
- Vite proxy: `/api``http://localhost:8000/api`
- States: loading, error (with error message), data
## TypeScript Types
```typescript
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>;
}
interface DashboardMetrics {
total_applications: number;
status_breakdown: Record<string, number>;
interviews_count: number;
offers_count: number;
rejection_rate: number;
}
```
## Acceptance Criteria
- [x] Home page at route `/`
- [x] MUI components exclusively (AppBar, Typography, Card, Grid, Button, Skeleton, Snackbar)
- [x] Last 3 job updates: job ID, status, creation date, metrics
- [x] "Create New Job Application" button navigates to `/applications/new`
- [x] Loading indicators while fetching data (Skeleton components)
- [x] Graceful error handling (Snackbar with error message)
- [x] Responsive layout (Grid breakpoints)
+6
View File
@@ -3,6 +3,12 @@
<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>
+6 -2
View File
@@ -5,10 +5,14 @@
"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",
"axios": "^1.7.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -18,7 +22,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.6.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+6 -2
View File
@@ -1,17 +1,21 @@
import { type ReactNode } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
import AppLayout from "./components/AppLayout";
import HomePage from "./pages/HomePage";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
export default function App() {
export default function App(): ReactNode {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/" element={<HomePage />} />
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
+40
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+50
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import App from "./App.tsx";
const rootElement = document.getElementById("root");
if (!rootElement) {
+83
View File
@@ -0,0 +1,83 @@
.container {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.card {
background-color: #fff;
padding: 2.5rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
}
.heading {
margin: 0 0 0.5rem;
}
.welcomeText {
color: #666;
margin-bottom: 1.5rem;
}
.signedInText {
margin-bottom: 0.5rem;
}
.emailStrong {
font-weight: 600;
}
.logoutButton {
padding: 0.5rem 1.25rem;
background-color: #b91c1c;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
}
.logoutButton:hover {
background-color: #991616;
}
.authLinks {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.signInLink {
padding: 0.5rem 1.25rem;
background-color: #1a73e8;
color: #fff;
text-decoration: none;
border-radius: 6px;
font-size: 0.9rem;
display: inline-block;
}
.signInLink:hover {
background-color: #1557b0;
}
.registerLink {
padding: 0.5rem 1.25rem;
background-color: #fff;
color: #1a73e8;
text-decoration: none;
border: 1px solid #1a73e8;
border-radius: 6px;
font-size: 0.9rem;
display: inline-block;
}
.registerLink:hover {
background-color: #f0f5ff;
}
+121 -70
View File
@@ -1,88 +1,139 @@
import { Link } from "react-router-dom";
import { type ReactNode } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
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";
import MetricsPanel from "../components/MetricsPanel";
import LoadingSkeleton from "../components/LoadingSkeleton";
import { useDashboardData } from "../hooks/useDashboardData";
export default function HomePage() {
const { state, logout } = useAuth();
function LandingView(): ReactNode {
return (
<div
style={{
minHeight: "100vh",
<Box
sx={{
minHeight: "60vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f5f5f5",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}}
>
<div
style={{
backgroundColor: "#fff",
padding: "2.5rem",
borderRadius: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
textAlign: "center",
}}
>
<h1 style={{ margin: "0 0 0.5rem" }}>Job Tracker</h1>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
Welcome to the Job Tracker application.
</p>
{state.isAuthenticated && state.user ? (
<div>
<p style={{ marginBottom: "0.5rem" }}>
Signed in as <strong>{state.user.email}</strong>
</p>
<button
onClick={logout}
style={{
padding: "0.5rem 1.25rem",
backgroundColor: "#b91c1c",
color: "#fff",
border: "none",
borderRadius: "6px",
fontSize: "0.9rem",
cursor: "pointer",
}}
>
Sign Out
</button>
</div>
) : (
<div style={{ display: "flex", gap: "0.75rem", justifyContent: "center" }}>
<Link
<Typography variant="h3" component="h1" gutterBottom sx={{ fontWeight: 700 }}>
Job Tracker
</Typography>
<Typography variant="h6" color="text.secondary" sx={{ mb: 4, maxWidth: 500 }}>
Track your job applications, interviews, and offers all in one place.
</Typography>
<Box sx={{ display: "flex", gap: 2 }}>
<Button
variant="contained"
size="large"
component={Link}
to="/login"
style={{
padding: "0.5rem 1.25rem",
backgroundColor: "#1a73e8",
color: "#fff",
textDecoration: "none",
borderRadius: "6px",
fontSize: "0.9rem",
}}
>
Sign In
</Link>
<Link
</Button>
<Button
variant="outlined"
size="large"
component={Link}
to="/register"
style={{
padding: "0.5rem 1.25rem",
backgroundColor: "#fff",
color: "#1a73e8",
textDecoration: "none",
border: "1px solid #1a73e8",
borderRadius: "6px",
fontSize: "0.9rem",
}}
>
Register
</Link>
</div>
)}
</div>
</div>
</Button>
</Box>
</Box>
);
}
function DashboardView(): 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>
);
}
export default function HomePage(): ReactNode {
const { state } = useAuth();
if (!state.isAuthenticated) {
return <LandingView />;
}
return <DashboardView />;
}
+114
View File
@@ -0,0 +1,114 @@
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.card {
background-color: #fff;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.title {
margin: 0 0 0.25rem;
font-size: 1.5rem;
font-weight: 600;
}
.subtitle {
margin: 0 0 1.5rem;
color: #666;
font-size: 0.9rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.label {
font-size: 0.85rem;
font-weight: 500;
color: #333;
}
.input {
padding: 0.6rem 0.75rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
}
.input:focus {
border-color: #1a73e8;
}
.button {
padding: 0.65rem;
background-color: #1a73e8;
color: #fff;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
margin-top: 0.5rem;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
background-color: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.success {
background-color: #f0fdf4;
color: #166534;
border: 1px solid #bbf7d0;
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.footer {
margin-top: 1.25rem;
text-align: center;
font-size: 0.85rem;
color: #666;
}
.link {
color: #1a73e8;
text-decoration: none;
font-weight: 500;
}
.link:hover {
text-decoration: underline;
}
+18 -117
View File
@@ -1,6 +1,7 @@
import { useState, type FormEvent, type ChangeEvent } from "react";
import { useNavigate, useLocation, Link } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import styles from "./LoginPage.module.css";
export default function LoginPage() {
const navigate = useNavigate();
@@ -33,17 +34,17 @@ export default function LoginPage() {
};
return (
<div style={styles.container}>
<div style={styles.card}>
<h1 style={styles.title}>Sign In</h1>
<p style={styles.subtitle}>Welcome back to Job Tracker</p>
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.title}>Sign In</h1>
<p className={styles.subtitle}>Welcome back to Job Tracker</p>
{state.error && <div style={styles.error}>{state.error}</div>}
{successMessage && <div style={styles.success}>{successMessage}</div>}
{state.error && <div className={styles.error}>{state.error}</div>}
{successMessage && <div className={styles.success}>{successMessage}</div>}
<form onSubmit={handleSubmit} style={styles.form}>
<div style={styles.field}>
<label htmlFor="email" style={styles.label}>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.field}>
<label htmlFor="email" className={styles.label}>
Email
</label>
<input
@@ -51,14 +52,14 @@ export default function LoginPage() {
type="email"
value={email}
onChange={handleEmailChange}
style={styles.input}
className={styles.input}
placeholder="you@example.com"
required
/>
</div>
<div style={styles.field}>
<label htmlFor="password" style={styles.label}>
<div className={styles.field}>
<label htmlFor="password" className={styles.label}>
Password
</label>
<input
@@ -66,8 +67,8 @@ export default function LoginPage() {
type="password"
value={password}
onChange={handlePasswordChange}
style={styles.input}
placeholder="&#xb7;&#xb7;&#xb7;&#xb7;&#xb7;&#xb7;&#xb7;&#xb7;"
className={styles.input}
placeholder="\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7\u00b7"
required
/>
</div>
@@ -75,18 +76,15 @@ export default function LoginPage() {
<button
type="submit"
disabled={state.isLoading}
style={{
...styles.button,
...(state.isLoading ? styles.buttonDisabled : {}),
}}
className={styles.button}
>
{state.isLoading ? "Signing in..." : "Sign In"}
</button>
</form>
<p style={styles.footer}>
<p className={styles.footer}>
Don't have an account?{" "}
<Link to="/register" style={styles.link}>
<Link to="/register" className={styles.link}>
Create one
</Link>
</p>
@@ -94,100 +92,3 @@ export default function LoginPage() {
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f5f5f5",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
card: {
backgroundColor: "#fff",
padding: "2rem",
borderRadius: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
width: "100%",
maxWidth: "400px",
},
title: {
margin: "0 0 0.25rem",
fontSize: "1.5rem",
fontWeight: 600,
},
subtitle: {
margin: "0 0 1.5rem",
color: "#666",
fontSize: "0.9rem",
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
field: {
display: "flex",
flexDirection: "column",
gap: "0.35rem",
},
label: {
fontSize: "0.85rem",
fontWeight: 500,
color: "#333",
},
input: {
padding: "0.6rem 0.75rem",
border: "1px solid #ccc",
borderRadius: "6px",
fontSize: "0.95rem",
outline: "none",
transition: "border-color 0.15s",
},
button: {
padding: "0.65rem",
backgroundColor: "#1a73e8",
color: "#fff",
border: "none",
borderRadius: "6px",
fontSize: "1rem",
fontWeight: 500,
cursor: "pointer",
marginTop: "0.5rem",
},
buttonDisabled: {
opacity: 0.6,
cursor: "not-allowed",
},
error: {
backgroundColor: "#fef2f2",
color: "#b91c1c",
border: "1px solid #fecaca",
borderRadius: "6px",
padding: "0.5rem 0.75rem",
fontSize: "0.85rem",
marginBottom: "0.5rem",
},
success: {
backgroundColor: "#f0fdf4",
color: "#166534",
border: "1px solid #bbf7d0",
borderRadius: "6px",
padding: "0.5rem 0.75rem",
fontSize: "0.85rem",
marginBottom: "0.5rem",
},
footer: {
marginTop: "1.25rem",
textAlign: "center",
fontSize: "0.85rem",
color: "#666",
},
link: {
color: "#1a73e8",
textDecoration: "none",
fontWeight: 500,
},
};
+120
View File
@@ -0,0 +1,120 @@
.container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.card {
background-color: #fff;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 440px;
}
.title {
margin: 0 0 0.25rem;
font-size: 1.5rem;
font-weight: 600;
}
.subtitle {
margin: 0 0 1.5rem;
color: #666;
font-size: 0.9rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.row {
display: flex;
gap: 0.75rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.halfField {
display: flex;
flex-direction: column;
gap: 0.35rem;
flex: 1;
}
.label {
font-size: 0.85rem;
font-weight: 500;
color: #333;
}
.required {
color: #b91c1c;
}
.input {
padding: 0.6rem 0.75rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
}
.input:focus {
border-color: #1a73e8;
}
.button {
padding: 0.65rem;
background-color: #1a73e8;
color: #fff;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
margin-top: 0.5rem;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
background-color: #fef2f2;
color: #b91c1c;
border: 1px solid #fecaca;
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.footer {
margin-top: 1.25rem;
text-align: center;
font-size: 0.85rem;
color: #666;
}
.link {
color: #1a73e8;
text-decoration: none;
font-weight: 500;
}
.link:hover {
text-decoration: underline;
}
+29 -132
View File
@@ -1,6 +1,7 @@
import { useState, type FormEvent, type ChangeEvent } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import styles from "./RegisterPage.module.css";
export default function RegisterPage() {
const navigate = useNavigate();
@@ -32,17 +33,17 @@ export default function RegisterPage() {
};
return (
<div style={styles.container}>
<div style={styles.card}>
<h1 style={styles.title}>Create Account</h1>
<p style={styles.subtitle}>Get started with Job Tracker</p>
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.title}>Create Account</h1>
<p className={styles.subtitle}>Get started with Job Tracker</p>
{state.error && <div style={styles.error}>{state.error}</div>}
{state.error && <div className={styles.error}>{state.error}</div>}
<form onSubmit={handleSubmit} style={styles.form}>
<div style={styles.row}>
<div style={styles.halfField}>
<label htmlFor="firstName" style={styles.label}>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.row}>
<div className={styles.halfField}>
<label htmlFor="firstName" className={styles.label}>
First Name
</label>
<input
@@ -52,11 +53,11 @@ export default function RegisterPage() {
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setFirstName(e.target.value)
}
style={styles.input}
className={styles.input}
/>
</div>
<div style={styles.halfField}>
<label htmlFor="lastName" style={styles.label}>
<div className={styles.halfField}>
<label htmlFor="lastName" className={styles.label}>
Last Name
</label>
<input
@@ -66,14 +67,14 @@ export default function RegisterPage() {
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setLastName(e.target.value)
}
style={styles.input}
className={styles.input}
/>
</div>
</div>
<div style={styles.field}>
<label htmlFor="email" style={styles.label}>
Email <span style={styles.required}>*</span>
<div className={styles.field}>
<label htmlFor="email" className={styles.label}>
Email <span className={styles.required}>*</span>
</label>
<input
id="email"
@@ -82,15 +83,15 @@ export default function RegisterPage() {
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setEmail(e.target.value)
}
style={styles.input}
className={styles.input}
placeholder="you@example.com"
required
/>
</div>
<div style={styles.field}>
<label htmlFor="password" style={styles.label}>
Password <span style={styles.required}>*</span>
<div className={styles.field}>
<label htmlFor="password" className={styles.label}>
Password <span className={styles.required}>*</span>
</label>
<input
id="password"
@@ -99,16 +100,16 @@ export default function RegisterPage() {
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setPassword(e.target.value)
}
style={styles.input}
className={styles.input}
placeholder="Min. 8 characters"
required
minLength={8}
/>
</div>
<div style={styles.field}>
<label htmlFor="passwordConfirm" style={styles.label}>
Confirm Password <span style={styles.required}>*</span>
<div className={styles.field}>
<label htmlFor="passwordConfirm" className={styles.label}>
Confirm Password <span className={styles.required}>*</span>
</label>
<input
id="passwordConfirm"
@@ -117,7 +118,7 @@ export default function RegisterPage() {
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setPasswordConfirm(e.target.value)
}
style={styles.input}
className={styles.input}
placeholder="Repeat your password"
required
minLength={8}
@@ -127,18 +128,15 @@ export default function RegisterPage() {
<button
type="submit"
disabled={state.isLoading}
style={{
...styles.button,
...(state.isLoading ? styles.buttonDisabled : {}),
}}
className={styles.button}
>
{state.isLoading ? "Creating Account..." : "Create Account"}
</button>
</form>
<p style={styles.footer}>
<p className={styles.footer}>
Already have an account?{" "}
<Link to="/login" style={styles.link}>
<Link to="/login" className={styles.link}>
Sign in
</Link>
</p>
@@ -146,104 +144,3 @@ export default function RegisterPage() {
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
container: {
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f5f5f5",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
},
card: {
backgroundColor: "#fff",
padding: "2rem",
borderRadius: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
width: "100%",
maxWidth: "440px",
},
title: {
margin: "0 0 0.25rem",
fontSize: "1.5rem",
fontWeight: 600,
},
subtitle: {
margin: "0 0 1.5rem",
color: "#666",
fontSize: "0.9rem",
},
form: {
display: "flex",
flexDirection: "column",
gap: "1rem",
},
row: {
display: "flex",
gap: "0.75rem",
},
field: {
display: "flex",
flexDirection: "column",
gap: "0.35rem",
},
halfField: {
display: "flex",
flexDirection: "column",
gap: "0.35rem",
flex: 1,
},
label: {
fontSize: "0.85rem",
fontWeight: 500,
color: "#333",
},
required: {
color: "#b91c1c",
},
input: {
padding: "0.6rem 0.75rem",
border: "1px solid #ccc",
borderRadius: "6px",
fontSize: "0.95rem",
outline: "none",
transition: "border-color 0.15s",
},
button: {
padding: "0.65rem",
backgroundColor: "#1a73e8",
color: "#fff",
border: "none",
borderRadius: "6px",
fontSize: "1rem",
fontWeight: 500,
cursor: "pointer",
marginTop: "0.5rem",
},
buttonDisabled: {
opacity: 0.6,
cursor: "not-allowed",
},
error: {
backgroundColor: "#fef2f2",
color: "#b91c1c",
border: "1px solid #fecaca",
borderRadius: "6px",
padding: "0.5rem 0.75rem",
fontSize: "0.85rem",
marginBottom: "0.5rem",
},
footer: {
marginTop: "1.25rem",
textAlign: "center",
fontSize: "0.85rem",
color: "#666",
},
link: {
color: "#1a73e8",
textDecoration: "none",
fontWeight: 500,
},
};
+24
View File
@@ -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;
}
+8
View File
@@ -1 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+2 -4
View File
@@ -1,6 +1,5 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
@@ -13,10 +12,9 @@
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
+6
View File
@@ -9,5 +9,11 @@ export default defineConfig({
watch: {
usePolling: true,
},
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
});
+1232
View File
File diff suppressed because it is too large Load Diff