From 6147071e0e89ecad050cc0c7d0d73e35af077bab Mon Sep 17 00:00:00 2001 From: Yousef Abdelhadi <101428643+yousef20920@users.noreply.github.com> Date: Sun, 2 Nov 2025 18:11:06 -0500 Subject: [PATCH 1/4] Finalize application journaling and visibility --- backend/app/models.py | 133 +- backend/app/routers/applications.py | 707 +++++++- backend/app/schemas.py | 172 +- .../dashboard/AddApplicationForm.tsx | 1159 +++++++------ .../dashboard/ApplicationDetailPage.tsx | 1429 ++++++++++++++++- .../components/dashboard/ApplicationsPage.tsx | 93 +- 6 files changed, 3068 insertions(+), 625 deletions(-) diff --git a/backend/app/models.py b/backend/app/models.py index 218ae64..d504ac0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -129,22 +129,49 @@ class Application(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(String(120), nullable=False, index=True) - company_id = Column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True) + company_id = Column( + UUID(as_uuid=True), + ForeignKey("companies.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + company_name = Column(String(255), nullable=True) role = Column(String(255), nullable=False) location = Column(String(255), nullable=True) position_type = Column(String(40), nullable=True) # internship, new_grad, experienced, other applied_date = Column(Date, nullable=False) - status = Column(String(40), nullable=False, default="active") # active, offer, rejected, withdrawn + posting_posted_date = Column(Date, nullable=True) + status = Column(String(40), nullable=False, default="active") # active, offer, rejected, withdrawn, waitlisted current_stage = Column(String(120), nullable=True) posting_url = Column(Text, nullable=True) + posting_html = Column(Text, nullable=True) + salary = Column(String(120), nullable=True) resume_version = Column(String(255), nullable=True) resume_file_path = Column(String(500), nullable=True) # Path to uploaded resume file notes = Column(Text, nullable=True) + visibility_preference = Column(String(40), nullable=False, default="private") created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) company = relationship("Company") - stages = relationship("ApplicationStage", back_populates="application", cascade="all, delete-orphan", order_by="ApplicationStage.position") + stages = relationship( + "ApplicationStage", + back_populates="application", + cascade="all, delete-orphan", + order_by="ApplicationStage.position", + ) + assessments = relationship( + "OnlineAssessment", + back_populates="application", + cascade="all, delete-orphan", + order_by="OnlineAssessment.created_at", + ) + interviews = relationship( + "InterviewRound", + back_populates="application", + cascade="all, delete-orphan", + order_by="InterviewRound.date_time", + ) class ApplicationStage(Base): @@ -160,3 +187,103 @@ class ApplicationStage(Base): created_at = Column(DateTime, default=datetime.utcnow, nullable=False) application = relationship("Application", back_populates="stages") + + +class OnlineAssessment(Base): + __tablename__ = "online_assessments" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + application_id = Column( + UUID(as_uuid=True), + ForeignKey("applications.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + platform = Column(String(120), nullable=False) + assessment_type = Column(String(40), nullable=True) + duration_minutes = Column(Integer, nullable=True) + score = Column(String(120), nullable=True) + languages = Column(ARRAY(String(60)), nullable=False, default=list) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + application = relationship("Application", back_populates="assessments") + questions = relationship( + "AssessmentQuestion", + back_populates="assessment", + cascade="all, delete-orphan", + order_by="AssessmentQuestion.created_at", + ) + + +class AssessmentQuestion(Base): + __tablename__ = "assessment_questions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + assessment_id = Column( + UUID(as_uuid=True), + ForeignKey("online_assessments.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + question_text = Column(Text, nullable=True) + topics = Column(ARRAY(String(120)), nullable=False, default=list) + difficulty = Column(String(40), nullable=True) + approach = Column(Text, nullable=True) + notes = Column(Text, nullable=True) + follow_up = Column(Text, nullable=True) + privacy_mode = Column(String(40), nullable=False, default="private") + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + assessment = relationship("OnlineAssessment", back_populates="questions") + + +class InterviewRound(Base): + __tablename__ = "interview_rounds" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + application_id = Column( + UUID(as_uuid=True), + ForeignKey("applications.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name = Column(String(120), nullable=False) + date_time = Column(DateTime, nullable=True) + duration_minutes = Column(Integer, nullable=True) + interview_type = Column(String(60), nullable=True) + format = Column(String(60), nullable=True) + interviewer = Column(String(120), nullable=True) + outcome = Column(String(40), nullable=True) + notes = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + application = relationship("Application", back_populates="interviews") + questions = relationship( + "InterviewQuestion", + back_populates="round", + cascade="all, delete-orphan", + order_by="InterviewQuestion.created_at", + ) + + +class InterviewQuestion(Base): + __tablename__ = "interview_questions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + round_id = Column( + UUID(as_uuid=True), + ForeignKey("interview_rounds.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + question_text = Column(Text, nullable=True) + topics = Column(ARRAY(String(120)), nullable=False, default=list) + difficulty = Column(String(40), nullable=True) + approach = Column(Text, nullable=True) + follow_up = Column(Text, nullable=True) + notes = Column(Text, nullable=True) + privacy_mode = Column(String(40), nullable=False, default="private") + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + round = relationship("InterviewRound", back_populates="questions") diff --git a/backend/app/routers/applications.py b/backend/app/routers/applications.py index ec2a0df..b395162 100644 --- a/backend/app/routers/applications.py +++ b/backend/app/routers/applications.py @@ -1,8 +1,10 @@ -from typing import List, Optional +from typing import Optional from uuid import UUID -import os +import re import shutil +from html.parser import HTMLParser from pathlib import Path +from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from fastapi.responses import FileResponse @@ -10,13 +12,35 @@ from sqlalchemy.orm import Session from ..dependencies import get_db -from ..models import Application, ApplicationStage, Company +from ..models import ( + Application, + ApplicationStage, + AssessmentQuestion, + Company, + InterviewQuestion, + InterviewRound, + OnlineAssessment, +) from ..schemas import ( ApplicationCreate, ApplicationDetail, ApplicationListItem, ApplicationsResponse, ApplicationUpdate, + AssessmentQuestionCreate, + AssessmentQuestionOut, + AssessmentQuestionUpdate, + InterviewQuestionCreate, + InterviewQuestionOut, + InterviewQuestionUpdate, + InterviewRoundCreate, + InterviewRoundOut, + InterviewRoundUpdate, + JobParseRequest, + JobParseResponse, + OnlineAssessmentCreate, + OnlineAssessmentOut, + OnlineAssessmentUpdate, StageCreate, StageUpdate, ) @@ -28,23 +52,150 @@ UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +class _HTMLTextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self._chunks: list[str] = [] + + def handle_data(self, data: str) -> None: + cleaned = data.strip() + if cleaned: + self._chunks.append(cleaned) + + def get_text(self) -> str: + return " \n".join(self._chunks) + + +def _strip_html(html: str) -> str: + parser = _HTMLTextExtractor() + parser.feed(html) + return parser.get_text() + + +def _extract_from_text(text: str) -> tuple[Optional[str], Optional[str], Optional[str]]: + company = None + role = None + location = None + + patterns = { + "company": re.compile(r"company\s*[:\-]\s*(.+)", re.IGNORECASE), + "role": re.compile(r"(role|position|title)\s*[:\-]\s*(.+)", re.IGNORECASE), + "location": re.compile(r"location\s*[:\-]\s*(.+)", re.IGNORECASE), + } + + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + continue + if company is None and (match := patterns["company"].search(stripped)): + company = match.group(1).strip() + continue + if role is None and (match := patterns["role"].search(stripped)): + role = match.group(2).strip() + continue + if location is None and (match := patterns["location"].search(stripped)): + location = match.group(1).strip() + + if role is None: + # attempt to use first non-empty line as role fallback + for line in text.splitlines(): + stripped = line.strip() + if stripped: + role = stripped + break + + return company, role, location + + +def _infer_company_from_url(url: str) -> Optional[str]: + try: + parsed = urlparse(url) + except ValueError: + return None + + hostname = parsed.hostname or "" + if not hostname: + return None + + parts = hostname.split(".") + if len(parts) < 2: + return None + + candidate = parts[0] + if candidate in {"www", "jobs", "careers"} and len(parts) > 1: + candidate = parts[1] + + return candidate.replace("-", " ").title() + + +def _create_assessment_question_model(payload: AssessmentQuestionCreate) -> AssessmentQuestion: + return AssessmentQuestion( + question_text=payload.question_text, + topics=payload.topics, + difficulty=payload.difficulty, + approach=payload.approach, + notes=payload.notes, + follow_up=payload.follow_up, + privacy_mode=payload.privacy_mode, + ) + + +def _create_interview_question_model(payload: InterviewQuestionCreate) -> InterviewQuestion: + return InterviewQuestion( + question_text=payload.question_text, + topics=payload.topics, + difficulty=payload.difficulty, + approach=payload.approach, + follow_up=payload.follow_up, + notes=payload.notes, + privacy_mode=payload.privacy_mode, + ) + + def _build_application_detail(app: Application) -> ApplicationDetail: """Helper function to build ApplicationDetail response with company name.""" return ApplicationDetail( id=app.id, company_id=app.company_id, - company_name=app.company.name if app.company else "Unknown Company", + company_name=app.company.name if app.company else app.company_name or "Unknown Company", role=app.role, location=app.location, position_type=app.position_type, applied_date=app.applied_date, + posting_posted_date=app.posting_posted_date, status=app.status, current_stage=app.current_stage, posting_url=app.posting_url, + posting_html=app.posting_html, resume_version=app.resume_version, notes=app.notes, + salary=app.salary, + visibility_preference=app.visibility_preference, stages=app.stages, + assessments=app.assessments, + interviews=app.interviews, + ) + + +def _get_application_or_404( + db: Session, + application_id: UUID, + user_id: str, +) -> Application: + stmt = ( + select(Application) + .where(Application.id == application_id) + .where(Application.user_id == user_id) ) + application = db.execute(stmt).scalar_one_or_none() + + if not application: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Application not found", + ) + + return application @router.get("", response_model=ApplicationsResponse) @@ -65,16 +216,19 @@ def list_applications( items = [ ApplicationListItem( id=app.id, - company_name=app.company.name if app.company else "Unknown", + company_name=app.company.name if app.company else app.company_name or "Unknown", company_id=app.company_id, role=app.role, location=app.location, position_type=app.position_type, applied_date=app.applied_date, + posting_posted_date=app.posting_posted_date, status=app.status, current_stage=app.current_stage or "Applied", posting_url=app.posting_url, + salary=app.salary, resume_version=app.resume_version, + visibility_preference=app.visibility_preference, ) for app in applications ] @@ -82,6 +236,37 @@ def list_applications( return ApplicationsResponse(items=items, total=len(items)) +@router.post("/parse", response_model=JobParseResponse) +def parse_job_posting(payload: JobParseRequest) -> JobParseResponse: + """Attempt to extract structured job posting data from user-provided content.""" + if not (payload.html or payload.text or payload.url): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provide at least one of html, text, or url to parse", + ) + + posting_html = payload.html or (payload.text if payload.text else None) + text_source = payload.text or "" + + if payload.html: + text_source = _strip_html(payload.html) + elif not text_source and payload.url: + text_source = payload.url + + company, role, location = _extract_from_text(text_source) + + if not company and payload.url: + company = _infer_company_from_url(payload.url) + + return JobParseResponse( + company_name=company, + role=role, + location=location, + posting_html=posting_html, + posting_text=text_source or None, + ) + + @router.get("/{application_id}", response_model=ApplicationDetail) def get_application( application_id: UUID, @@ -89,18 +274,7 @@ def get_application( db: Session = Depends(get_db), ): """Get detailed information about a specific application.""" - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - application = db.execute(stmt).scalar_one_or_none() - - if not application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + application = _get_application_or_404(db, application_id, user_id) return _build_application_detail(application) @@ -113,29 +287,43 @@ def create_application( ): """Create a new application.""" # Verify company exists - company = db.execute( - select(Company).where(Company.id == application.company_id) - ).scalar_one_or_none() - - if not company: + company = None + if application.company_id: + company = db.execute( + select(Company).where(Company.id == application.company_id) + ).scalar_one_or_none() + + if not company: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Company not found", + ) + elif not application.company_name: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Company not found", + status_code=status.HTTP_400_BAD_REQUEST, + detail="Either company_id or company_name must be provided", ) + company_name = company.name if company else application.company_name + # Create application db_application = Application( user_id=user_id, company_id=application.company_id, + company_name=company_name, role=application.role, location=application.location, position_type=application.position_type, applied_date=application.applied_date, + posting_posted_date=application.posting_posted_date, status="active", current_stage="Applied", posting_url=application.posting_url, + posting_html=application.posting_html, + salary=application.salary, resume_version=application.resume_version, notes=application.notes, + visibility_preference=application.visibility_preference or "private", ) # Create initial "Applied" stage @@ -162,18 +350,7 @@ def update_application( db: Session = Depends(get_db), ): """Update an existing application.""" - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - db_application = db.execute(stmt).scalar_one_or_none() - - if not db_application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + db_application = _get_application_or_404(db, application_id, user_id) # Update fields update_data = application.model_dump(exclude_unset=True) @@ -193,18 +370,7 @@ def delete_application( db: Session = Depends(get_db), ): """Delete an application.""" - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - db_application = db.execute(stmt).scalar_one_or_none() - - if not db_application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + db_application = _get_application_or_404(db, application_id, user_id) db.delete(db_application) db.commit() @@ -219,18 +385,7 @@ def add_stage( db: Session = Depends(get_db), ): """Add a new stage to an application timeline.""" - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - db_application = db.execute(stmt).scalar_one_or_none() - - if not db_application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + db_application = _get_application_or_404(db, application_id, user_id) # Get the highest position max_position = max([s.position for s in db_application.stages], default=-1) @@ -251,10 +406,16 @@ def add_stage( db_application.current_stage = stage.name # Update status based on stage - if stage.name in ["Offer", "Accepted"]: - db_application.status = "offer" - elif stage.name in ["Rejected", "Withdrawn"]: - db_application.status = "rejected" + normalized_name = stage.name.strip().lower() + status_updates = { + "offer": "offer", + "accepted": "offer", + "rejected": "rejected", + "withdrawn": "withdrawn", + "waitlisted": "waitlisted", + } + if normalized_name in status_updates: + db_application.status = status_updates[normalized_name] db.commit() db.refresh(db_application) @@ -272,18 +433,7 @@ def update_stage( ): """Update an existing stage.""" # Verify application ownership - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - db_application = db.execute(stmt).scalar_one_or_none() - - if not db_application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + db_application = _get_application_or_404(db, application_id, user_id) # Find and update stage db_stage = next((s for s in db_application.stages if s.id == stage_id), None) @@ -314,18 +464,7 @@ def delete_stage( ): """Delete a stage from an application.""" # Verify application ownership - stmt = ( - select(Application) - .where(Application.id == application_id) - .where(Application.user_id == user_id) - ) - db_application = db.execute(stmt).scalar_one_or_none() - - if not db_application: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Application not found", - ) + db_application = _get_application_or_404(db, application_id, user_id) # Find and delete stage db_stage = next((s for s in db_application.stages if s.id == stage_id), None) @@ -356,6 +495,398 @@ def delete_stage( return _build_application_detail(db_application) +@router.post("/{application_id}/assessments", response_model=ApplicationDetail) +def add_assessment( + application_id: UUID, + payload: OnlineAssessmentCreate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + + db_assessment = OnlineAssessment( + application_id=application_id, + platform=payload.platform, + assessment_type=payload.assessment_type, + duration_minutes=payload.duration_minutes, + score=payload.score, + languages=payload.languages, + notes=payload.notes, + ) + + for question_payload in payload.questions: + db_assessment.questions.append(_create_assessment_question_model(question_payload)) + + db.add(db_assessment) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.put("/{application_id}/assessments/{assessment_id}", response_model=ApplicationDetail) +def update_assessment( + application_id: UUID, + assessment_id: UUID, + payload: OnlineAssessmentUpdate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_assessment = next((a for a in db_application.assessments if a.id == assessment_id), None) + + if not db_assessment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment not found", + ) + + update_data = payload.model_dump(exclude_unset=True) + + for field in ["platform", "assessment_type", "duration_minutes", "score", "notes"]: + if field in update_data: + setattr(db_assessment, field, update_data[field]) + + if "languages" in update_data: + db_assessment.languages = update_data["languages"] + + if payload.questions is not None: + for existing in list(db_assessment.questions): + db.delete(existing) + db.flush() + for question_payload in payload.questions: + db_assessment.questions.append(_create_assessment_question_model(question_payload)) + + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.delete("/{application_id}/assessments/{assessment_id}", response_model=ApplicationDetail) +def delete_assessment( + application_id: UUID, + assessment_id: UUID, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_assessment = next((a for a in db_application.assessments if a.id == assessment_id), None) + + if not db_assessment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment not found", + ) + + db.delete(db_assessment) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.post( + "/{application_id}/assessments/{assessment_id}/questions", + response_model=ApplicationDetail, +) +def add_assessment_question( + application_id: UUID, + assessment_id: UUID, + payload: AssessmentQuestionCreate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_assessment = next((a for a in db_application.assessments if a.id == assessment_id), None) + + if not db_assessment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment not found", + ) + + db_assessment.questions.append(_create_assessment_question_model(payload)) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.put( + "/{application_id}/assessments/{assessment_id}/questions/{question_id}", + response_model=ApplicationDetail, +) +def update_assessment_question( + application_id: UUID, + assessment_id: UUID, + question_id: UUID, + payload: AssessmentQuestionUpdate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_assessment = next((a for a in db_application.assessments if a.id == assessment_id), None) + + if not db_assessment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment not found", + ) + + db_question = next((q for q in db_assessment.questions if q.id == question_id), None) + + if not db_question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment question not found", + ) + + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_question, field, value) + + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.delete( + "/{application_id}/assessments/{assessment_id}/questions/{question_id}", + response_model=ApplicationDetail, +) +def delete_assessment_question( + application_id: UUID, + assessment_id: UUID, + question_id: UUID, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_assessment = next((a for a in db_application.assessments if a.id == assessment_id), None) + + if not db_assessment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment not found", + ) + + db_question = next((q for q in db_assessment.questions if q.id == question_id), None) + + if not db_question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Assessment question not found", + ) + + db.delete(db_question) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.post("/{application_id}/interviews", response_model=ApplicationDetail) +def add_interview_round( + application_id: UUID, + payload: InterviewRoundCreate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + + db_round = InterviewRound( + application_id=application_id, + name=payload.name, + date_time=payload.date_time, + duration_minutes=payload.duration_minutes, + interview_type=payload.interview_type, + format=payload.format, + interviewer=payload.interviewer, + outcome=payload.outcome, + notes=payload.notes, + ) + + for question_payload in payload.questions: + db_round.questions.append(_create_interview_question_model(question_payload)) + + db.add(db_round) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.put("/{application_id}/interviews/{round_id}", response_model=ApplicationDetail) +def update_interview_round( + application_id: UUID, + round_id: UUID, + payload: InterviewRoundUpdate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_round = next((r for r in db_application.interviews if r.id == round_id), None) + + if not db_round: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview round not found", + ) + + update_data = payload.model_dump(exclude_unset=True) + + for field in [ + "name", + "date_time", + "duration_minutes", + "interview_type", + "format", + "interviewer", + "outcome", + "notes", + ]: + if field in update_data: + setattr(db_round, field, update_data[field]) + + if payload.questions is not None: + for existing in list(db_round.questions): + db.delete(existing) + db.flush() + for question_payload in payload.questions: + db_round.questions.append(_create_interview_question_model(question_payload)) + + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.delete("/{application_id}/interviews/{round_id}", response_model=ApplicationDetail) +def delete_interview_round( + application_id: UUID, + round_id: UUID, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_round = next((r for r in db_application.interviews if r.id == round_id), None) + + if not db_round: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview round not found", + ) + + db.delete(db_round) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.post( + "/{application_id}/interviews/{round_id}/questions", + response_model=ApplicationDetail, +) +def add_interview_question( + application_id: UUID, + round_id: UUID, + payload: InterviewQuestionCreate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_round = next((r for r in db_application.interviews if r.id == round_id), None) + + if not db_round: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview round not found", + ) + + db_round.questions.append(_create_interview_question_model(payload)) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.put( + "/{application_id}/interviews/{round_id}/questions/{question_id}", + response_model=ApplicationDetail, +) +def update_interview_question( + application_id: UUID, + round_id: UUID, + question_id: UUID, + payload: InterviewQuestionUpdate, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_round = next((r for r in db_application.interviews if r.id == round_id), None) + + if not db_round: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview round not found", + ) + + db_question = next((q for q in db_round.questions if q.id == question_id), None) + + if not db_question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview question not found", + ) + + update_data = payload.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_question, field, value) + + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + +@router.delete( + "/{application_id}/interviews/{round_id}/questions/{question_id}", + response_model=ApplicationDetail, +) +def delete_interview_question( + application_id: UUID, + round_id: UUID, + question_id: UUID, + user_id: str = "demo-user", + db: Session = Depends(get_db), +): + db_application = _get_application_or_404(db, application_id, user_id) + db_round = next((r for r in db_application.interviews if r.id == round_id), None) + + if not db_round: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview round not found", + ) + + db_question = next((q for q in db_round.questions if q.id == question_id), None) + + if not db_question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Interview question not found", + ) + + db.delete(db_question) + db.commit() + db.refresh(db_application) + + return _build_application_detail(db_application) + + @router.post("/{application_id}/upload-resume") async def upload_resume( application_id: UUID, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d08be14..3084071 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -123,15 +123,18 @@ class ApplicationStageSchema(BaseModel): class ApplicationListItem(BaseModel): id: UUID company_name: str - company_id: UUID + company_id: Optional[UUID] role: str location: Optional[str] position_type: Optional[str] applied_date: date + posting_posted_date: Optional[date] status: str current_stage: Optional[str] posting_url: Optional[str] + salary: Optional[str] resume_version: Optional[str] + visibility_preference: str model_config = { "from_attributes": True, @@ -140,18 +143,24 @@ class ApplicationListItem(BaseModel): class ApplicationDetail(BaseModel): id: UUID - company_id: UUID + company_id: Optional[UUID] company_name: str role: str location: Optional[str] position_type: Optional[str] applied_date: date + posting_posted_date: Optional[date] status: str current_stage: Optional[str] posting_url: Optional[str] + posting_html: Optional[str] resume_version: Optional[str] notes: Optional[str] + salary: Optional[str] + visibility_preference: str stages: List[ApplicationStageSchema] + assessments: List["OnlineAssessmentOut"] + interviews: List["InterviewRoundOut"] model_config = { "from_attributes": True, @@ -169,25 +178,35 @@ class Config: class ApplicationCreate(BaseModel): - company_id: UUID + company_id: Optional[UUID] = None + company_name: Optional[str] = None role: str location: Optional[str] = None position_type: Optional[str] = None applied_date: date + posting_posted_date: Optional[date] = None posting_url: Optional[str] = None + posting_html: Optional[str] = None + salary: Optional[str] = None resume_version: Optional[str] = None notes: Optional[str] = None + visibility_preference: Optional[str] = Field(default="private") class ApplicationUpdate(BaseModel): + company_name: Optional[str] = None role: Optional[str] = None location: Optional[str] = None position_type: Optional[str] = None status: Optional[str] = None current_stage: Optional[str] = None posting_url: Optional[str] = None + posting_html: Optional[str] = None + posting_posted_date: Optional[date] = None + salary: Optional[str] = None resume_version: Optional[str] = None notes: Optional[str] = None + visibility_preference: Optional[str] = None class StageCreate(BaseModel): @@ -204,6 +223,148 @@ class StageUpdate(BaseModel): notes: Optional[str] = None +class AssessmentQuestionBase(BaseModel): + question_text: Optional[str] = Field(default=None, alias="question") + topics: List[str] = Field(default_factory=list) + difficulty: Optional[str] = None + approach: Optional[str] = None + notes: Optional[str] = None + follow_up: Optional[str] = Field(default=None, alias="followUp") + privacy_mode: str = Field(default="private", alias="privacyMode") + + model_config = { + "populate_by_name": True, + } + + +class AssessmentQuestionCreate(AssessmentQuestionBase): + pass + + +class AssessmentQuestionUpdate(AssessmentQuestionBase): + pass + + +class AssessmentQuestionOut(AssessmentQuestionBase): + id: UUID + + model_config = { + "populate_by_name": True, + "from_attributes": True, + } + + +class OnlineAssessmentBase(BaseModel): + platform: str + assessment_type: Optional[str] = Field(default=None, alias="type") + duration_minutes: Optional[int] = Field(default=None, alias="duration") + score: Optional[str] = None + languages: List[str] = Field(default_factory=list) + notes: Optional[str] = None + + model_config = { + "populate_by_name": True, + } + + +class OnlineAssessmentCreate(OnlineAssessmentBase): + questions: List[AssessmentQuestionCreate] = Field(default_factory=list) + + +class OnlineAssessmentUpdate(OnlineAssessmentBase): + questions: Optional[List[AssessmentQuestionCreate]] = None + + +class OnlineAssessmentOut(OnlineAssessmentBase): + id: UUID + created_at: datetime + questions: List[AssessmentQuestionOut] + + model_config = { + "populate_by_name": True, + "from_attributes": True, + } + + +class InterviewQuestionBase(BaseModel): + question_text: Optional[str] = Field(default=None, alias="question") + topics: List[str] = Field(default_factory=list) + difficulty: Optional[str] = None + approach: Optional[str] = None + follow_up: Optional[str] = Field(default=None, alias="followUp") + notes: Optional[str] = None + privacy_mode: str = Field(default="private", alias="privacyMode") + + model_config = { + "populate_by_name": True, + } + + +class InterviewQuestionCreate(InterviewQuestionBase): + pass + + +class InterviewQuestionUpdate(InterviewQuestionBase): + pass + + +class InterviewQuestionOut(InterviewQuestionBase): + id: UUID + + model_config = { + "populate_by_name": True, + "from_attributes": True, + } + + +class InterviewRoundBase(BaseModel): + name: str + date_time: Optional[datetime] = Field(default=None, alias="dateTime") + duration_minutes: Optional[int] = Field(default=None, alias="duration") + interview_type: Optional[str] = Field(default=None, alias="type") + format: Optional[str] = None + interviewer: Optional[str] = None + outcome: Optional[str] = None + notes: Optional[str] = None + + model_config = { + "populate_by_name": True, + } + + +class InterviewRoundCreate(InterviewRoundBase): + questions: List[InterviewQuestionCreate] = Field(default_factory=list) + + +class InterviewRoundUpdate(InterviewRoundBase): + questions: Optional[List[InterviewQuestionCreate]] = None + + +class InterviewRoundOut(InterviewRoundBase): + id: UUID + created_at: datetime + questions: List[InterviewQuestionOut] + + model_config = { + "populate_by_name": True, + "from_attributes": True, + } + + +class JobParseRequest(BaseModel): + url: Optional[str] = None + html: Optional[str] = None + text: Optional[str] = None + + +class JobParseResponse(BaseModel): + company_name: Optional[str] = None + role: Optional[str] = None + location: Optional[str] = None + posting_html: Optional[str] = None + posting_text: Optional[str] = None + + class JobRoleOut(BaseModel): id: UUID title: str @@ -233,3 +394,8 @@ class PlatformOut(BaseModel): model_config = { "from_attributes": True, } + + +ApplicationDetail.model_rebuild() +OnlineAssessmentOut.model_rebuild() +InterviewRoundOut.model_rebuild() diff --git a/frontend/src/components/dashboard/AddApplicationForm.tsx b/frontend/src/components/dashboard/AddApplicationForm.tsx index 5e62792..c71964f 100644 --- a/frontend/src/components/dashboard/AddApplicationForm.tsx +++ b/frontend/src/components/dashboard/AddApplicationForm.tsx @@ -1,639 +1,794 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { fetchJson, searchRoles, searchLocations, type JobRole, type Location } from '../../lib/api'; +import { + fetchJson, + searchRoles, + searchLocations, + type JobRole, + type Location +} from '../../lib/api'; import type { Company } from '../../types'; -import { MagnifyingGlassIcon, ChevronDownIcon } from '../icons/Icons'; +import { + MagnifyingGlassIcon, + ChevronDownIcon, + DocumentTextIcon, + RocketLaunchIcon, + BookmarkIcon +} from '../icons/Icons'; interface FormData { company_id: string; + company_name: string; role: string; location: string; position_type: string; applied_date: string; + posting_posted_date: string; posting_url: string; + posting_html: string; + salary: string; resume_version: string; notes: string; + visibility_preference: 'private' | 'anonymous'; } +interface JobParseResult { + company_name?: string | null; + role?: string | null; + location?: string | null; + posting_html?: string | null; + posting_text?: string | null; +} + +const POSITION_TYPES: Array<{ label: string; value: string }> = [ + { label: 'Internship', value: 'internship' }, + { label: 'New Grad', value: 'new_grad' }, + { label: 'Experienced', value: 'experienced' }, + { label: 'Other', value: 'other' } +]; + +const VISIBILITY_OPTIONS: Array<{ label: string; value: 'private' | 'anonymous'; description: string }> = [ + { + label: 'Private workspace', + value: 'private', + description: 'Only you can view this application, assessments, and notes.' + }, + { + label: 'Share anonymously', + value: 'anonymous', + description: 'Let PathLine surface aggregated insights without showing your identity.' + } +]; + +const createToday = () => new Date().toISOString().split('T')[0]; + export function AddApplicationForm() { const navigate = useNavigate(); const [companies, setCompanies] = useState([]); - const [filteredCompanies, setFilteredCompanies] = useState([]); - const [roles, setRoles] = useState([]); - const [filteredRoles, setFilteredRoles] = useState([]); - const [locations, setLocations] = useState([]); - const [filteredLocations, setFilteredLocations] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [roleSearchQuery, setRoleSearchQuery] = useState(''); - const [locationSearchQuery, setLocationSearchQuery] = useState(''); - const [showDropdown, setShowDropdown] = useState(false); + const [companySearch, setCompanySearch] = useState(''); + const [showCompanyDropdown, setShowCompanyDropdown] = useState(false); + const [roleQuery, setRoleQuery] = useState(''); const [showRoleDropdown, setShowRoleDropdown] = useState(false); + const [roleSuggestions, setRoleSuggestions] = useState([]); + const [locationQuery, setLocationQuery] = useState(''); const [showLocationDropdown, setShowLocationDropdown] = useState(false); - const [selectedCompany, setSelectedCompany] = useState(null); - const [selectedRole, setSelectedRole] = useState(null); - const [selectedLocation, setSelectedLocation] = useState(null); + const [locationSuggestions, setLocationSuggestions] = useState([]); + const [useManualCompany, setUseManualCompany] = useState(false); const [resumeFile, setResumeFile] = useState(null); - const searchInputRef = useRef(null); - const dropdownRef = useRef(null); - const roleSearchInputRef = useRef(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [parseLoading, setParseLoading] = useState(false); + const [parseMessage, setParseMessage] = useState(null); + const [quickUrl, setQuickUrl] = useState(''); + const [quickContent, setQuickContent] = useState(''); + + const companyInputRef = useRef(null); + const companyDropdownRef = useRef(null); + const roleInputRef = useRef(null); const roleDropdownRef = useRef(null); - const locationSearchInputRef = useRef(null); + const locationInputRef = useRef(null); const locationDropdownRef = useRef(null); - + const [formData, setFormData] = useState({ company_id: '', + company_name: '', role: '', location: '', - position_type: '', - applied_date: new Date().toISOString().split('T')[0], + position_type: 'new_grad', + applied_date: createToday(), + posting_posted_date: '', posting_url: '', + posting_html: '', + salary: '', resume_version: '', notes: '', + visibility_preference: 'private' }); useEffect(() => { - // Load all companies initially fetchJson('/api/companies?limit=100') - .then(data => { + .then((data) => { const response = data as { items: Company[] }; - setCompanies(response.items); - setFilteredCompanies(response.items); + setCompanies(response.items ?? []); }) - .catch(err => console.error('Failed to load companies:', err)); + .catch((err) => console.error('Failed to load companies', err)); }, []); - // Search companies when query changes - useEffect(() => { - if (searchQuery.trim().length === 0) { - setFilteredCompanies(companies); - } else { - // Client-side filtering for instant results - const filtered = companies.filter(company => - company.name.toLowerCase().includes(searchQuery.toLowerCase()) - ); - setFilteredCompanies(filtered); - - // Also fetch from server for complete results - if (searchQuery.length >= 2) { - fetchJson(`/api/companies/search?q=${encodeURIComponent(searchQuery)}`) - .then(data => { - const response = data as { items: Company[] }; - setFilteredCompanies(response.items); - }) - .catch(err => console.error('Failed to search companies:', err)); - } - } - }, [searchQuery, companies]); + const filteredCompanies = useMemo(() => { + if (!companySearch.trim()) return companies; + return companies.filter((company) => + company.name.toLowerCase().includes(companySearch.toLowerCase()) + ); + }, [companies, companySearch]); - // Close dropdown when clicking outside useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { + const handler = (event: MouseEvent) => { if ( - dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - searchInputRef.current && - !searchInputRef.current.contains(event.target as Node) + companyDropdownRef.current && + !companyDropdownRef.current.contains(event.target as Node) && + companyInputRef.current && + !companyInputRef.current.contains(event.target as Node) ) { - setShowDropdown(false); + setShowCompanyDropdown(false); } if ( roleDropdownRef.current && !roleDropdownRef.current.contains(event.target as Node) && - roleSearchInputRef.current && - !roleSearchInputRef.current.contains(event.target as Node) + roleInputRef.current && + !roleInputRef.current.contains(event.target as Node) ) { setShowRoleDropdown(false); } if ( locationDropdownRef.current && !locationDropdownRef.current.contains(event.target as Node) && - locationSearchInputRef.current && - !locationSearchInputRef.current.contains(event.target as Node) + locationInputRef.current && + !locationInputRef.current.contains(event.target as Node) ) { setShowLocationDropdown(false); } }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); }, []); - // Search roles when query changes useEffect(() => { - if (roleSearchQuery.trim().length >= 2) { - searchRoles(roleSearchQuery, 20) - .then(data => { - setFilteredRoles(data); - }) - .catch(err => console.error('Failed to search roles:', err)); - } else { - setFilteredRoles([]); + if (roleQuery.trim().length < 2) { + setRoleSuggestions([]); + return; } - }, [roleSearchQuery]); - // Search locations when query changes + let cancelled = false; + searchRoles(roleQuery, 15) + .then((results) => { + if (!cancelled) { + setRoleSuggestions(results); + } + }) + .catch((err) => { + if (err.name !== 'AbortError') { + console.error('Failed to search roles', err); + } + }); + + return () => { + cancelled = true; + }; + }, [roleQuery]); + useEffect(() => { - if (locationSearchQuery.trim().length >= 2) { - searchLocations(locationSearchQuery, 20) - .then(data => { - setFilteredLocations(data); - }) - .catch(err => console.error('Failed to search locations:', err)); - } else { - setFilteredLocations([]); + if (locationQuery.trim().length < 2) { + setLocationSuggestions([]); + return; } - }, [locationSearchQuery]); + + let cancelled = false; + searchLocations(locationQuery, 15) + .then((results) => { + if (!cancelled) { + setLocationSuggestions(results); + } + }) + .catch((err) => { + if (err.name !== 'AbortError') { + console.error('Failed to search locations', err); + } + }); + + return () => { + cancelled = true; + }; + }, [locationQuery]); const handleCompanySelect = (company: Company) => { - setSelectedCompany(company); - setSearchQuery(company.name); - setFormData({ ...formData, company_id: company.id }); - setShowDropdown(false); + setUseManualCompany(false); + setCompanySearch(company.name); + setFormData((prev) => ({ + ...prev, + company_id: String(company.id), + company_name: company.name + })); + setShowCompanyDropdown(false); }; const handleRoleSelect = (role: JobRole) => { - setSelectedRole(role); - setRoleSearchQuery(role.title); - setFormData({ ...formData, role: role.title }); + setFormData((prev) => ({ ...prev, role: role.title })); + setRoleQuery(role.title); setShowRoleDropdown(false); }; const handleLocationSelect = (location: Location) => { - setSelectedLocation(location); - const locationString = location.country && location.country !== 'Unknown' - ? `${location.city}, ${location.country}` - : location.city; - setLocationSearchQuery(locationString); - setFormData({ ...formData, location: locationString }); + const formatted = [location.city, location.country].filter(Boolean).join(', '); + setFormData((prev) => ({ ...prev, location: formatted })); + setLocationQuery(formatted); setShowLocationDropdown(false); }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - // Validate company selection - if (!selectedCompany || !formData.company_id) { - setError('Please select a company from the list'); + const handleInputChange = ( + event: React.ChangeEvent + ) => { + const { name, value } = event.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleResumeChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const allowedTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'text/plain' + ]; + + if (!allowedTypes.includes(file.type)) { + setError('Please upload a PDF, DOC, DOCX, or TXT file.'); + return; + } + + if (file.size > 5 * 1024 * 1024) { + setError('Resume files must be smaller than 5MB.'); + return; + } + + setResumeFile(file); + setError(null); + }; + + const applyParseResult = (result: JobParseResult) => { + if (!result) return; + + setFormData((prev) => ({ + ...prev, + posting_html: result.posting_html ?? result.posting_text ?? prev.posting_html + })); + + if (result.role) { + setFormData((prev) => ({ ...prev, role: result.role ?? prev.role })); + setRoleQuery(result.role ?? ''); + } + + if (result.location) { + setFormData((prev) => ({ ...prev, location: result.location ?? prev.location })); + setLocationQuery(result.location ?? ''); + } + + if (result.company_name) { + const match = companies.find( + (company) => company.name.toLowerCase() === result.company_name?.toLowerCase() + ); + + if (match) { + handleCompanySelect(match); + } else { + setUseManualCompany(true); + setCompanySearch(result.company_name); + setFormData((prev) => ({ + ...prev, + company_id: '', + company_name: result.company_name ?? prev.company_name + })); + } + } + }; + + const handleParseJobPosting = async () => { + if (!quickUrl.trim() && !quickContent.trim()) { + setParseMessage('Add a job link or paste the job description to auto-fill details.'); return; } - + + setParseLoading(true); + setParseMessage(null); + setError(null); + + try { + const response = await fetch('/api/applications/parse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: quickUrl.trim() || undefined, + html: quickContent.trim() || undefined, + text: quickContent.trim() || undefined + }) + }); + + if (!response.ok) { + throw new Error('Unable to parse the job posting.'); + } + + const data: JobParseResult = await response.json(); + applyParseResult(data); + setParseMessage('We pre-filled everything we could. Double-check before saving.'); + setFormData((prev) => ({ ...prev, posting_url: quickUrl.trim() })); + } catch (err) { + setParseMessage(null); + setError(err instanceof Error ? err.message : 'Failed to parse the job posting.'); + } finally { + setParseLoading(false); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); setLoading(true); setError(null); + setParseMessage(null); + + const trimmedCompanyName = formData.company_name.trim(); + const hasCompany = formData.company_id || trimmedCompanyName; + const normalizedPostingUrl = formData.posting_url || quickUrl.trim(); + const normalizedPostingHtml = formData.posting_html || quickContent.trim(); + + if (!hasCompany) { + setLoading(false); + setError('Select a company or enter one manually.'); + return; + } + + if (!formData.role.trim()) { + setLoading(false); + setError('Add the role title you applied for.'); + return; + } try { - // Create the application first + const payload = { + company_id: formData.company_id ? formData.company_id : undefined, + company_name: !formData.company_id ? trimmedCompanyName : undefined, + role: formData.role, + location: formData.location || undefined, + position_type: formData.position_type || undefined, + applied_date: formData.applied_date, + posting_posted_date: formData.posting_posted_date || undefined, + posting_url: normalizedPostingUrl || undefined, + posting_html: normalizedPostingHtml || undefined, + salary: formData.salary || undefined, + resume_version: formData.resume_version || undefined, + notes: formData.notes || undefined, + visibility_preference: formData.visibility_preference + }; + const response = await fetch('/api/applications', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - company_id: formData.company_id, - role: formData.role, - location: formData.location || null, - position_type: formData.position_type || null, - applied_date: formData.applied_date, - posting_url: formData.posting_url || null, - resume_version: resumeFile ? resumeFile.name.split('.')[0] : (formData.resume_version || null), - notes: formData.notes || null, - }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) }); if (!response.ok) { - throw new Error('Failed to create application'); + throw new Error('Failed to create the application.'); } const newApplication = await response.json(); - - // Upload resume if file is selected + if (resumeFile) { - const uploadFormData = new FormData(); - uploadFormData.append('file', resumeFile); - - const uploadResponse = await fetch(`/api/applications/${newApplication.id}/upload-resume`, { + const uploadData = new FormData(); + uploadData.append('file', resumeFile); + + await fetch(`/api/applications/${newApplication.id}/upload-resume`, { method: 'POST', - body: uploadFormData, + body: uploadData }); - - if (!uploadResponse.ok) { - console.error('Failed to upload resume, but application was created'); - } } - - navigate(`/applications/${newApplication.id}`); + + navigate(`/dashboard/applications/${newApplication.id}`); } catch (err) { - setError(err instanceof Error ? err.message : 'An error occurred'); + setError(err instanceof Error ? err.message : 'Something went wrong while saving.'); } finally { setLoading(false); } }; - const handleChange = (e: React.ChangeEvent) => { - setFormData({ - ...formData, - [e.target.name]: e.target.value, - }); - }; + return ( +
+ - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - // Validate file type - const allowedTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain']; - if (!allowedTypes.includes(file.type)) { - setError('Please upload a PDF, DOC, DOCX, or TXT file'); - return; - } - - // Validate file size (max 5MB) - if (file.size > 5 * 1024 * 1024) { - setError('File size must be less than 5MB'); - return; - } - - setResumeFile(file); - setError(null); - } - }; +
+
+

Log a new application

+

+ Capture the posting, stage, and privacy preferences in one place. We’ll start your timeline + at “Applied” and you can build from there. +

+
- return ( -
-
- -
+
+
+
+
+ +
+
+
+
+

Quick capture

+

+ Paste a job link or the HTML / text description. We’ll auto-detect the basics for you. +

+
+ +
-
-

Add New Application

+
+ +