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)