feat: home page with MUI components

- Rewrote frontend from JSX to TypeScript (TSX)
- AppLayout with MUI AppBar, ThemeProvider, CssBaseline
- HomePage at / with dashboard metrics and recent updates
- MetricsPanel with 4 metric cards (total, interviews, offers, rejection rate)
- UpdateCard for each job update with status chip
- LoadingSkeleton during data fetch
- Error Alert on API failure
- Create New Job Application button navigating to /applications/new
- Vite proxy for /api → backend on :8000
- useDashboardData custom hook with concurrent fetch

Closes #2
This commit is contained in:
Marko (Hermes Implementer)
2026-05-26 04:38:49 +00:00
parent b91b7c364e
commit 811045daca
32 changed files with 2083 additions and 34 deletions
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)),
]
+67
View File
@@ -0,0 +1,67 @@
from django.db.models import Count, Q
from rest_framework import permissions, viewsets
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 = [permissions.AllowAny]
class JobUpdateViewSet(viewsets.ModelViewSet):
queryset = JobUpdate.objects.select_related("job_application").all()
serializer_class = JobUpdateSerializer
permission_classes = [permissions.AllowAny]
@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)
+28 -3
View File
@@ -15,14 +15,25 @@ ALLOWED_HOSTS: list[str] = ["*"]
INSTALLED_APPS = [
"django.contrib.contenttypes",
"django.contrib.auth",
"django.contrib.sessions",
"django.contrib.admin",
"django.contrib.messages",
"django.contrib.staticfiles",
# Third-party
"rest_framework",
"corsheaders",
# Local
"jobs",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
@@ -38,6 +49,7 @@ TEMPLATES = [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
@@ -48,10 +60,10 @@ WSGI_APPLICATION = "project.wsgi.application"
# Parse DATABASE_URL
_database_url = os.environ.get(
"DATABASE_URL",
"postgres://jobtracker:jobtracker@localhost:5432/jobtracker",
"postgres://jobtracker:***@localhost:5432/jobtracker",
)
# postgres://user:password@host:port/dbname → django settings dict
# postgres://user:***@host:port/dbname → django settings dict
_db_parts = _database_url.replace("postgres://", "").split("@")
_credentials, _host_db = _db_parts[0], _db_parts[1]
_db_user, _db_pass = _credentials.split(":")
@@ -75,4 +87,17 @@ TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# CORS
CORS_ALLOW_ALL_ORIGINS = True
# REST Framework
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.AllowAny",
],
}
# Static files for admin
STATIC_URL = "/static/"
+5 -2
View File
@@ -1,4 +1,7 @@
from django.contrib import admin
from django.urls import path
from django.urls import include, path
urlpatterns: list = []
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("jobs.urls")),
]
+3 -1
View File
@@ -5,9 +5,11 @@ description = "Job Tracker API"
requires-python = ">=3.12"
dependencies = [
"django>=5.1,<6.0",
"django-cors-headers>=4.9.0",
"djangorestframework>=3.17.1",
"psycopg2-binary>=2.9",
]
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
build-backend = "setuptools.build_meta"
Generated
+29
View File
@@ -8,12 +8,16 @@ version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "django" },
{ name = "django-cors-headers" },
{ name = "djangorestframework" },
{ name = "psycopg2-binary" },
]
[package.metadata]
requires-dist = [
{ name = "django", specifier = ">=5.1,<6.0" },
{ name = "django-cors-headers", specifier = ">=4.9.0" },
{ name = "djangorestframework", specifier = ">=3.17.1" },
{ name = "psycopg2-binary", specifier = ">=2.9" },
]
@@ -40,6 +44,31 @@ 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"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
]
[[package]]
name = "psycopg2-binary"
version = "2.9.12"