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:
@@ -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"]
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class JobsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'jobs'
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user