Implement: Home Page with MUI Components #13
@@ -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,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)
|
||||||
+35
-2
@@ -15,14 +15,25 @@ ALLOWED_HOSTS: list[str] = ["*"]
|
|||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
# Third-party
|
||||||
|
"rest_framework",
|
||||||
|
"corsheaders",
|
||||||
|
# Local
|
||||||
|
"jobs",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -38,6 +49,7 @@ TEMPLATES = [
|
|||||||
"django.template.context_processors.debug",
|
"django.template.context_processors.debug",
|
||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -48,10 +60,10 @@ WSGI_APPLICATION = "project.wsgi.application"
|
|||||||
# Parse DATABASE_URL
|
# Parse DATABASE_URL
|
||||||
_database_url = os.environ.get(
|
_database_url = os.environ.get(
|
||||||
"DATABASE_URL",
|
"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("@")
|
_db_parts = _database_url.replace("postgres://", "").split("@")
|
||||||
_credentials, _host_db = _db_parts[0], _db_parts[1]
|
_credentials, _host_db = _db_parts[0], _db_parts[1]
|
||||||
_db_user, _db_pass = _credentials.split(":")
|
_db_user, _db_pass = _credentials.split(":")
|
||||||
@@ -76,3 +88,24 @@ USE_I18N = True
|
|||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
# 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(",")
|
||||||
|
|
||||||
|
# REST Framework
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
|
],
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||||
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||||
|
"rest_framework.authentication.SessionAuthentication",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files for admin
|
||||||
|
STATIC_URL = "/static/"
|
||||||
+5
-2
@@ -1,4 +1,7 @@
|
|||||||
from django.contrib import admin
|
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")),
|
||||||
|
]
|
||||||
@@ -5,6 +5,8 @@ description = "Job Tracker API"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django>=5.1,<6.0",
|
"django>=5.1,<6.0",
|
||||||
|
"django-cors-headers>=4.9.0",
|
||||||
|
"djangorestframework>=3.17.1",
|
||||||
"psycopg2-binary>=2.9",
|
"psycopg2-binary>=2.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Generated
+29
@@ -8,12 +8,16 @@ version = "1.0.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django" },
|
{ name = "django" },
|
||||||
|
{ name = "django-cors-headers" },
|
||||||
|
{ name = "djangorestframework" },
|
||||||
{ name = "psycopg2-binary" },
|
{ name = "psycopg2-binary" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "django", specifier = ">=5.1,<6.0" },
|
{ 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" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "psycopg2-binary"
|
name = "psycopg2-binary"
|
||||||
version = "2.9.12"
|
version = "2.9.12"
|
||||||
|
|||||||
@@ -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)
|
||||||
+7
-1
@@ -3,10 +3,16 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>Job Tracker</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+10
-2
@@ -5,15 +5,23 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
"vite": "^6.0.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: {
|
watch: {
|
||||||
usePolling: true,
|
usePolling: true,
|
||||||
},
|
},
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:8000",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user