From ace3c5d72e6479833cbddd8333c5772e172ccf5f Mon Sep 17 00:00:00 2001 From: "Leonardo B." Date: Sat, 21 Mar 2026 23:13:46 -0300 Subject: [PATCH 1/3] Add edit support for application history Add PATCH endpoint and UI to edit application history entries. Backend: introduce ApplicationHistoryUpdate schema, implement update_history_entry with validation and current_stage recalculation, factor out _get_latest_history_entry and reuse it in delete_history_entry. Frontend: add updateHistoryEntry API and types, extend StageHistoryDialog with an edit dialog (fields: stage, date, notes) and wire up save/cancel flows, plus related UI/type fixes across components (ApplicationDetailDialog variable usage, ApplicationTable ColumnDef typing, calendar/appointment locale/timeFormat propagation, timezone label display, and a minor api-client typing fix). --- backend/app/routes/application_history.py | 16 +- backend/app/schemas/application_history.py | 6 + backend/app/services/application_history.py | 74 +++++- .../applications/ApplicationDetailDialog.tsx | 15 +- .../web/app/applications/ApplicationTable.tsx | 14 +- frontend/apps/web/app/calendar/page.tsx | 4 +- frontend/apps/web/app/settings/page.tsx | 2 +- .../web/components/AppointmentListDialog.tsx | 8 + .../web/components/StageHistoryDialog.tsx | 246 +++++++++++++----- frontend/apps/web/services/api-client.ts | 2 +- .../apps/web/services/applications.service.ts | 13 + frontend/apps/web/types/api.generated.ts | 48 +++- frontend/apps/web/types/index.ts | 1 + 13 files changed, 346 insertions(+), 103 deletions(-) diff --git a/backend/app/routes/application_history.py b/backend/app/routes/application_history.py index 8f67df1..897d337 100644 --- a/backend/app/routes/application_history.py +++ b/backend/app/routes/application_history.py @@ -2,7 +2,11 @@ from sqlalchemy.orm import Session from app.core.database import get_db -from app.schemas.application_history import ApplicationHistoryCreate, ApplicationHistoryResponse +from app.schemas.application_history import ( + ApplicationHistoryCreate, + ApplicationHistoryResponse, + ApplicationHistoryUpdate, +) from app.services import application_history as history_service router = APIRouter(prefix="/api/applications", tags=["application-history"]) @@ -20,6 +24,16 @@ def add_history_entry( return history_service.advance_stage(db, application_id, data) +@router.patch("/{application_id}/history/{history_id}", response_model=ApplicationHistoryResponse) +def patch_history_entry( + application_id: int, + history_id: int, + data: ApplicationHistoryUpdate, + db: Session = Depends(get_db), +) -> ApplicationHistoryResponse: + return history_service.update_history_entry(db, application_id, history_id, data) + + @router.delete("/{application_id}/history/{history_id}", status_code=204) def delete_history_entry( application_id: int, history_id: int, db: Session = Depends(get_db) diff --git a/backend/app/schemas/application_history.py b/backend/app/schemas/application_history.py index edc1e44..f33a871 100644 --- a/backend/app/schemas/application_history.py +++ b/backend/app/schemas/application_history.py @@ -10,6 +10,12 @@ class ApplicationHistoryCreate(BaseModel): notes: Optional[str] = None +class ApplicationHistoryUpdate(BaseModel): + stage: Optional[str] = None + date: Optional[datetime] = None + notes: Optional[str] = None + + class ApplicationHistoryResponse(BaseModel): model_config = {"from_attributes": True} diff --git a/backend/app/services/application_history.py b/backend/app/services/application_history.py index adaaa70..a26c792 100644 --- a/backend/app/services/application_history.py +++ b/backend/app/services/application_history.py @@ -3,8 +3,7 @@ from app.models.application import Application from app.models.application_history import ApplicationHistory -from app.schemas.application_history import ApplicationHistoryCreate -from app.core import utcnow +from app.schemas.application_history import ApplicationHistoryCreate, ApplicationHistoryUpdate def get_history(db: Session, application_id: int) -> list[ApplicationHistory]: @@ -16,6 +15,18 @@ def get_history(db: Session, application_id: int) -> list[ApplicationHistory]: ) +def _get_latest_history_entry( + db: Session, + application_id: int, + *, + exclude_history_id: int | None = None, +) -> ApplicationHistory | None: + q = db.query(ApplicationHistory).filter(ApplicationHistory.application_id == application_id) + if exclude_history_id is not None: + q = q.filter(ApplicationHistory.id != exclude_history_id) + return q.order_by(ApplicationHistory.date.desc()).first() + + def _append_history_and_update_stage( db: Session, application: Application, @@ -52,6 +63,55 @@ def advance_stage(db: Session, application_id: int, data: ApplicationHistoryCrea return entry +def update_history_entry( + db: Session, + application_id: int, + history_id: int, + data: ApplicationHistoryUpdate, +) -> ApplicationHistory: + entry = ( + db.query(ApplicationHistory) + .filter( + ApplicationHistory.id == history_id, + ApplicationHistory.application_id == application_id, + ) + .first() + ) + if entry is None: + raise HTTPException(status_code=404, detail="History entry not found") + + updates = data.model_dump(exclude_unset=True) + if not updates: + raise HTTPException(status_code=400, detail="At least one field must be provided") + + if "stage" in updates: + stage_val = updates["stage"] + if stage_val is None or (isinstance(stage_val, str) and not stage_val.strip()): + raise HTTPException(status_code=400, detail="Stage cannot be empty") + entry.stage = stage_val.strip() + + if "date" in updates: + date_val = updates["date"] + if date_val is None: + raise HTTPException(status_code=400, detail="Date cannot be null") + entry.date = date_val + + if "notes" in updates: + entry.notes = updates["notes"] + + application = db.query(Application).filter(Application.id == application_id).first() + if application is None: + raise HTTPException(status_code=404, detail="Application not found") + + latest = _get_latest_history_entry(db, application_id) + if latest is not None: + application.current_stage = latest.stage + + db.commit() + db.refresh(entry) + return entry + + def delete_history_entry(db: Session, application_id: int, history_id: int) -> bool: entry = ( db.query(ApplicationHistory) @@ -77,15 +137,7 @@ def delete_history_entry(db: Session, application_id: int, history_id: int) -> b db.delete(entry) - latest = ( - db.query(ApplicationHistory) - .filter( - ApplicationHistory.application_id == application_id, - ApplicationHistory.id != history_id, - ) - .order_by(ApplicationHistory.date.desc()) - .first() - ) + latest = _get_latest_history_entry(db, application_id, exclude_history_id=history_id) application = db.query(Application).filter(Application.id == application_id).first() if application and latest: application.current_stage = latest.stage diff --git a/frontend/apps/web/app/applications/ApplicationDetailDialog.tsx b/frontend/apps/web/app/applications/ApplicationDetailDialog.tsx index 41d3fbb..144d578 100644 --- a/frontend/apps/web/app/applications/ApplicationDetailDialog.tsx +++ b/frontend/apps/web/app/applications/ApplicationDetailDialog.tsx @@ -60,28 +60,29 @@ export function ApplicationDetailDialog({ return null } + const app = application + const companyRecord = - application.company_id != null - ? companies.find((c) => c.id === application.company_id) + app.company_id != null + ? companies.find((c) => c.id === app.company_id) : undefined function closeAndEdit() { onOpenChange(false) - onEdit(application) + onEdit(app) } function closeAndHistory() { onOpenChange(false) - onOpenHistory(application.id) + onOpenHistory(app.id) } function closeAndAppointments() { onOpenChange(false) - onOpenAppointments(application) + onOpenAppointments(app) } - const salaryNum = - application.salary != null && application.salary !== "" ? Number(application.salary) : null + const salaryNum = app.salary != null && app.salary !== "" ? Number(app.salary) : null const salaryText = salaryNum != null && !Number.isNaN(salaryNum) ? new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(salaryNum) diff --git a/frontend/apps/web/app/applications/ApplicationTable.tsx b/frontend/apps/web/app/applications/ApplicationTable.tsx index 4327c61..8a2a9a9 100644 --- a/frontend/apps/web/app/applications/ApplicationTable.tsx +++ b/frontend/apps/web/app/applications/ApplicationTable.tsx @@ -129,7 +129,7 @@ export function ApplicationTable({ ) const columns = useMemo((): ColumnDef[] => { - const base: ColumnDef[] = [ + const base = [ columnHelper.accessor("job_title", { header: "Job Title", cell: ({ row, getValue }) => ( @@ -163,7 +163,7 @@ export function ApplicationTable({ header: "Date Applied", cell: (info) => formatDate(info.getValue(), locale), }), - ] + ] as ColumnDef[] const optional: ColumnDef[] = [] @@ -184,7 +184,7 @@ export function ApplicationTable({ ) }, sortingFn: "alphanumeric", - }), + }) as ColumnDef, ) } @@ -220,7 +220,7 @@ export function ApplicationTable({ return av === bv ? 0 : av < bv ? -1 : 1 }, }, - ), + ) as ColumnDef, ) } @@ -235,7 +235,7 @@ export function ApplicationTable({ ) : ( ), - }), + }) as ColumnDef, ) } @@ -245,7 +245,7 @@ export function ApplicationTable({ id: "created_at", header: "Created", cell: (info) => formatDate(info.getValue(), locale), - }), + }) as ColumnDef, ) } @@ -382,7 +382,7 @@ export function ApplicationTable({ ) }, - }) + }) as ColumnDef return [...base, ...optional, actions] }, [tablePrefs, platforms, resumeMap, locale, archived, onEdit, handleArchive, handleRestore, handleDelete]) diff --git a/frontend/apps/web/app/calendar/page.tsx b/frontend/apps/web/app/calendar/page.tsx index f0d5a27..87dbceb 100644 --- a/frontend/apps/web/app/calendar/page.tsx +++ b/frontend/apps/web/app/calendar/page.tsx @@ -256,10 +256,10 @@ export default function CalendarPage() { {sortedAppointments.map((appt) => ( - {formatDate(appt.starts_at)} + {formatDate(appt.starts_at, locale)} - {formatTimeRange(appt.starts_at, appt.ends_at)} + {formatTimeRange(appt.starts_at, appt.ends_at, locale, timeFormat)} {appt.title} diff --git a/frontend/apps/web/app/settings/page.tsx b/frontend/apps/web/app/settings/page.tsx index 3a91837..73f40a0 100644 --- a/frontend/apps/web/app/settings/page.tsx +++ b/frontend/apps/web/app/settings/page.tsx @@ -308,7 +308,7 @@ function TimezoneCombobox({ value, onChange }: TimezoneComboboxProps) { aria-label="Select timezone" > - {label === "" ? "Auto" : label} + {label} diff --git a/frontend/apps/web/components/AppointmentListDialog.tsx b/frontend/apps/web/components/AppointmentListDialog.tsx index b7a57f5..866bdb1 100644 --- a/frontend/apps/web/components/AppointmentListDialog.tsx +++ b/frontend/apps/web/components/AppointmentListDialog.tsx @@ -108,6 +108,8 @@ export function AppointmentListDialog({ @@ -124,6 +126,8 @@ export function AppointmentListDialog({ void onDelete: (id: number) => void isPast?: boolean diff --git a/frontend/apps/web/components/StageHistoryDialog.tsx b/frontend/apps/web/components/StageHistoryDialog.tsx index 8d7af36..8f89201 100644 --- a/frontend/apps/web/components/StageHistoryDialog.tsx +++ b/frontend/apps/web/components/StageHistoryDialog.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useEffect, useState } from "react" import { toast } from "sonner" import { AlertDialog, @@ -14,14 +14,15 @@ import { AlertDialogTrigger, } from "@workspace/ui/components/alert-dialog" import { Button } from "@workspace/ui/components/button" -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@workspace/ui/components/dialog" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@workspace/ui/components/dialog" import { Input } from "@workspace/ui/components/input" import { Label } from "@workspace/ui/components/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@workspace/ui/components/select" import { Separator } from "@workspace/ui/components/separator" import { Textarea } from "@workspace/ui/components/textarea" import { useApplicationHistory } from "@/hooks/useApplicationHistory" -import { advanceStage, deleteHistoryEntry } from "@/services/applications.service" +import { advanceStage, deleteHistoryEntry, updateHistoryEntry } from "@/services/applications.service" +import type { ApplicationHistoryResponse } from "@/types" const STAGE_OPTIONS = ["application", "screening", "interview", "assessment", "offer", "closed"] @@ -50,6 +51,25 @@ export function StageHistoryDialog({ applicationId, open, onOpenChange, onStageC const [newNotes, setNewNotes] = useState("") const [submitting, setSubmitting] = useState(false) + const [editingEntry, setEditingEntry] = useState(null) + const [editStage, setEditStage] = useState("") + const [editDate, setEditDate] = useState("") + const [editNotes, setEditNotes] = useState("") + const [editSubmitting, setEditSubmitting] = useState(false) + + useEffect(() => { + if (!open) { + setEditingEntry(null) + } + }, [open]) + + function openEdit(entry: ApplicationHistoryResponse) { + setEditingEntry(entry) + setEditStage(entry.stage) + setEditDate(new Date(entry.date).toISOString().slice(0, 16)) + setEditNotes(entry.notes ?? "") + } + async function handleAddEntry() { if (!newStage) { toast.error("Stage is required") @@ -85,60 +105,137 @@ export function StageHistoryDialog({ applicationId, open, onOpenChange, onStageC } } + async function handleSaveEdit() { + if (!editingEntry) return + if (!editStage.trim()) { + toast.error("Stage is required") + return + } + setEditSubmitting(true) + const result = await updateHistoryEntry(applicationId, editingEntry.id, { + stage: editStage.trim(), + date: new Date(editDate).toISOString(), + notes: editNotes.trim() === "" ? null : editNotes.trim(), + }) + setEditSubmitting(false) + if (result.error) { + toast.error(result.error) + } else { + toast.success("Entry updated") + setEditingEntry(null) + await refetch() + onStageChanged?.() + } + } + return ( - - - - Stage History - - -
- {isLoading ? ( -

Loading…

- ) : history.length === 0 ? ( -

No history entries.

- ) : ( - history.map((entry) => ( -
-
-

{entry.stage}

-

{formatDateTime(entry.date)}

- {entry.notes &&

{entry.notes}

} -
- - - - - - - Delete history entry? - - This will remove the "{entry.stage}" stage entry. The current stage will be - recalculated from remaining entries. - - - - Cancel - handleDelete(entry.id)}>Delete - - - -
- )) - )} -
+ + + + + + + Delete history entry? + + This will remove the "{entry.stage}" stage entry. The current stage will be + recalculated from remaining entries. + + + + Cancel + handleDelete(entry.id)}>Delete + + + + + + )) + )} + - + -
-

Add Stage Entry

-
+
+

Add Stage Entry

+
+
+ + +
+
+ + setNewDate(e.target.value)} + /> +
+
- -