diff --git a/apps/backend/fastapi/app/api/routes/submissions.py b/apps/backend/fastapi/app/api/routes/submissions.py index a11a583..225bc94 100644 --- a/apps/backend/fastapi/app/api/routes/submissions.py +++ b/apps/backend/fastapi/app/api/routes/submissions.py @@ -8,6 +8,7 @@ from app.schemas import ( AiSuggestionRequest, SubmissionCreateRequest, SubmissionResponse, + SubmissionStatusUpdateRequest, SuggestionResponse, SuggestionUpdateRequest, ) @@ -16,8 +17,10 @@ from app.services.submissions import ( get_submission, list_submissions, request_ai_suggestions, + update_submission_status, update_suggestion, ) +from app.models import SubmissionStatus from dlib.auth import AuthenticatedUser @@ -40,7 +43,9 @@ async def get_submission_endpoint( session: AsyncSession = Depends(get_db), user: AuthenticatedUser = Depends(get_current_user), ): - submission = await get_submission(session, owner_id=user.sub, submission_id=submission_id) + submission = await get_submission( + session, owner_id=user.sub, submission_id=submission_id + ) if not submission: raise HTTPException(status_code=404, detail="Submission not found") return SubmissionResponse.model_validate(submission) @@ -66,6 +71,24 @@ async def create_submission_endpoint( return SubmissionResponse.model_validate(submission) +@router.patch("/{submission_id}/status", response_model=SubmissionResponse) +async def update_submission_status_endpoint( + submission_id: str, + payload: SubmissionStatusUpdateRequest, + session: AsyncSession = Depends(get_db), + user: AuthenticatedUser = Depends(get_current_user), +): + submission = await update_submission_status( + session, + owner_id=user.sub, + submission_id=submission_id, + status=SubmissionStatus(payload.status), + ) + if not submission: + raise HTTPException(status_code=404, detail="Submission not found") + return SubmissionResponse.model_validate(submission) + + @router.post("/{submission_id}/ai", response_model=list[SuggestionResponse]) async def request_submissions_ai( submission_id: str, @@ -85,7 +108,9 @@ async def request_submissions_ai( return [SuggestionResponse.model_validate(item) for item in suggestions] -@router.patch("/{submission_id}/suggestions/{suggestion_id}", response_model=SuggestionResponse) +@router.patch( + "/{submission_id}/suggestions/{suggestion_id}", response_model=SuggestionResponse +) async def update_suggestion_endpoint( submission_id: str, suggestion_id: str, diff --git a/apps/backend/fastapi/app/schemas/__init__.py b/apps/backend/fastapi/app/schemas/__init__.py index ee67932..b0a6f39 100644 --- a/apps/backend/fastapi/app/schemas/__init__.py +++ b/apps/backend/fastapi/app/schemas/__init__.py @@ -11,6 +11,7 @@ from .cv import ( PublishRequest, SubmissionCreateRequest, SubmissionResponse, + SubmissionStatusUpdateRequest, SuggestionResponse, SuggestionUpdateRequest, VersionResponse, @@ -25,6 +26,7 @@ __all__ = [ "PatchApplyRequest", "SubmissionCreateRequest", "SubmissionResponse", + "SubmissionStatusUpdateRequest", "AiSuggestionRequest", "SuggestionResponse", "SuggestionUpdateRequest", diff --git a/apps/backend/fastapi/app/schemas/cv.py b/apps/backend/fastapi/app/schemas/cv.py index 9a0d645..1dc2abe 100644 --- a/apps/backend/fastapi/app/schemas/cv.py +++ b/apps/backend/fastapi/app/schemas/cv.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field @@ -90,6 +90,16 @@ class SubmissionCreateRequest(BaseModel): job_description: str | None = None +class SubmissionStatusUpdateRequest(BaseModel): + status: Literal[ + "draft", + "tailoring", + "pending_review", + "published", + "archived", + ] + + class SuggestionResponse(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/apps/backend/fastapi/app/services/submissions.py b/apps/backend/fastapi/app/services/submissions.py index 1fcb30e..0185466 100644 --- a/apps/backend/fastapi/app/services/submissions.py +++ b/apps/backend/fastapi/app/services/submissions.py @@ -133,6 +133,22 @@ async def update_suggestion( return suggestion +async def update_submission_status( + session: AsyncSession, + *, + owner_id: str, + submission_id: str, + status: SubmissionStatus, +) -> Submission | None: + submission = await _get_submission_for_owner(session, owner_id, submission_id) + if not submission: + return None + submission.status = status + await session.commit() + await session.refresh(submission) + return submission + + async def _get_version_for_owner( session: AsyncSession, owner_id: str, version_id: str ) -> CvVersion | None: diff --git a/apps/webapp/src/app/dashboard/page.tsx b/apps/webapp/src/app/dashboard/page.tsx index c1d8b1f..c0194f8 100644 --- a/apps/webapp/src/app/dashboard/page.tsx +++ b/apps/webapp/src/app/dashboard/page.tsx @@ -11,7 +11,14 @@ import { fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl, publishVersion, PublicAsset, PublicAssetAnalytics, requestAiSuggestions, - Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version, + Submission, + SubmissionStatus, + StructuredBlock, + Suggestion, + updateSubmissionStatus, + updateSuggestion, + uploadDocument, + Version, } from '@/libs/api'; // ── helpers ─────────────────────────────────────────────────────────────────── @@ -20,12 +27,32 @@ function fmt(iso: string) { return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } -function statusBadge(status: string) { +const SUBMISSION_STATUS_LABELS: Record = { + draft: 'Draft', + tailoring: 'Tailoring', + pending_review: 'Passed screening', + published: 'Submitted', + archived: 'Closed', +}; + +const SUBMISSION_STATUS_OPTIONS: Array<{ value: SubmissionStatus; label: string }> = [ + { value: 'draft', label: 'Draft' }, + { value: 'tailoring', label: 'Tailoring' }, + { value: 'published', label: 'Submitted' }, + { value: 'pending_review', label: 'Passed screening' }, + { value: 'archived', label: 'Closed' }, +]; + +function isSubmittedStatus(status: SubmissionStatus) { + return status === 'published' || status === 'pending_review' || status === 'archived'; +} + +function statusBadge(status: SubmissionStatus) { const cls = ({ draft: 'badge-draft', tailoring: 'badge-submitted', pending_review: 'badge-interviewing', published: 'badge-public', archived: 'badge-closed', - } as Record)[status] ?? 'badge-draft'; - return {status.replace('_', ' ')}; + } as Record)[status] ?? 'badge-draft'; + return {SUBMISSION_STATUS_LABELS[status] ?? status.replace('_', ' ')}; } // ── modals ──────────────────────────────────────────────────────────────────── @@ -304,20 +331,27 @@ function ContentTab({ // ── submissions tab ─────────────────────────────────────────────────────────── function SubmissionsTab({ - submissions, loading, versionId, - onNewSubmission, onRefresh, + submissions, loading, + onNewSubmission, onRefresh, onStatusChange, }: { submissions: Submission[]; loading: boolean; - versionId: string; onNewSubmission: () => void; onRefresh: () => void; + onStatusChange: (submissionId: string, status: SubmissionStatus) => void; }) { const [expanded, setExpanded] = useState(null); const [aiLoading, setAiLoading] = useState(null); + const [statusLoading, setStatusLoading] = useState(null); const [aiJd, setAiJd] = useState>({}); const [suggestions, setSuggestions] = useState>({}); + const submittedCount = submissions.filter(s => isSubmittedStatus(s.status)).length; + const passedScreeningCount = submissions.filter(s => s.status === 'pending_review').length; + const successRate = submittedCount > 0 + ? Math.round((passedScreeningCount / submittedCount) * 100) + : 0; + const loadAi = async (s: Submission) => { const jd = aiJd[s.id] ?? s.job_description ?? ''; if (!jd.trim()) return; @@ -340,6 +374,19 @@ function SubmissionsTab({ } catch { /* ignore */ } }; + const changeStatus = async (sub: Submission, nextStatus: SubmissionStatus) => { + if (sub.status === nextStatus) return; + setStatusLoading(sub.id); + try { + const updated = await updateSubmissionStatus(sub.id, nextStatus); + onStatusChange(updated.id, updated.status); + } catch { + // ignore for now + } finally { + setStatusLoading(null); + } + }; + if (loading) return
Loading…
; return ( @@ -349,6 +396,21 @@ function SubmissionsTab({ +
+
+
Submitted
+
{submittedCount}
+
+
+
Passed screening
+
{passedScreeningCount}
+
+
+
Success rate
+
{successRate}%
+
+
+ {submissions.length === 0 && (
No submissions yet. Create one to track a job application and get AI tailoring suggestions. @@ -389,6 +451,20 @@ function SubmissionsTab({ )} +
+
Application stage
+ +
+ {/* AI tailoring */}
AI tailoring
@@ -485,6 +561,7 @@ export default function Dashboard() { const [recentlyPublishedSlug, setRecentlyPublishedSlug] = useState(null); const [activeTab, setActiveTab] = useState('content'); const [submissions, setSubmissions] = useState([]); + const [allSubmissions, setAllSubmissions] = useState([]); const [subsLoading, setSubsLoading] = useState(false); const [pendingEdits, setPendingEdits] = useState>(new Map()); const [sidebarOpen, setSidebarOpen] = useState(false); @@ -493,9 +570,10 @@ export default function Dashboard() { const [applyError, setApplyError] = useState(''); useEffect(() => { - fetchDocuments() - .then(d => { + Promise.all([fetchDocuments(), fetchSubmissions().catch(() => [])]) + .then(([d, allSubs]) => { setDocs(d); + setAllSubmissions(allSubs); if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); } }) .catch(() => setError('Failed to load documents. Make sure the backend is running.')) @@ -521,6 +599,13 @@ export default function Dashboard() { const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null; const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null; + const selectedDocVersionIds = new Set((selectedDoc?.versions ?? []).map(v => v.id)); + const selectedDocSubmissions = allSubmissions.filter(s => selectedDocVersionIds.has(s.version_id)); + const selectedDocSubmittedCount = selectedDocSubmissions.filter(s => isSubmittedStatus(s.status)).length; + const selectedDocPassedScreeningCount = selectedDocSubmissions.filter(s => s.status === 'pending_review').length; + const selectedDocSuccessRate = selectedDocSubmittedCount > 0 + ? Math.round((selectedDocPassedScreeningCount / selectedDocSubmittedCount) * 100) + : 0; const publishedAssets = selectedVersion?.public_assets ?? []; const sortedPublishedAssets = [...publishedAssets].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); const publicBaseUrl = (process.env.NEXT_PUBLIC_BASE_URL ?? '').replace(/\/$/, ''); @@ -543,6 +628,7 @@ export default function Dashboard() { const refreshDocs = async () => { const fresh = await fetchDocuments().catch(() => docs); setDocs(fresh); + refreshAllSubs(); return fresh; }; @@ -551,6 +637,10 @@ export default function Dashboard() { fetchSubmissions(selectedVersionId).then(setSubmissions).catch(() => { }); }; + function refreshAllSubs() { + fetchSubmissions().then(setAllSubmissions).catch(() => { }); + } + const onUploadDone = (doc: Document) => { setDocs(prev => [doc, ...prev.filter(d => d.id !== doc.id)]); setSelectedDocId(doc.id); @@ -569,10 +659,16 @@ export default function Dashboard() { const onSubmissionDone = (s: Submission) => { setSubmissions(prev => [s, ...prev]); + setAllSubmissions(prev => [s, ...prev]); setModal(null); setActiveTab('submissions'); }; + const handleSubmissionStatusChange = (submissionId: string, status: SubmissionStatus) => { + setSubmissions(prev => prev.map(s => (s.id === submissionId ? { ...s, status } : s))); + setAllSubmissions(prev => prev.map(s => (s.id === submissionId ? { ...s, status } : s))); + }; + const stageEdit = (path: string, old_value: string, new_value: string) => { setPendingEdits(prev => new Map(prev).set(path, { old_value, new_value })); }; @@ -610,7 +706,6 @@ export default function Dashboard() { }; const handleDeleteVersion = async (versionId: string) => { - const version = selectedDoc?.versions.find(v => v.id === versionId); const hasChildren = selectedDoc?.versions.some(v => v.parent_version_id === versionId); const msg = hasChildren ? 'Delete this branch and all its sub-branches? This cannot be undone.' @@ -767,6 +862,50 @@ export default function Dashboard() { {selectedVersion && ( <> + {selectedDoc && ( +
+
+
+
+
Dashboard overview
+
+ {selectedDoc.title} +
+
+ {selectedDoc.versions.length} version{selectedDoc.versions.length !== 1 ? 's' : ''} +
+ +
+
+
Submissions
+
{selectedDocSubmissions.length}
+
+
+
Submitted
+
{selectedDocSubmittedCount}
+
+
+
Passed screening
+
{selectedDocPassedScreeningCount}
+
+
+
Success rate
+
{selectedDocSuccessRate}%
+
+
+ +
+
Full branch tree
+ +
+
+
+ )} + {/* version header */}
@@ -934,9 +1073,9 @@ export default function Dashboard() { setModal('submission')} onRefresh={refreshSubs} + onStatusChange={handleSubmissionStatusChange} /> )}
diff --git a/apps/webapp/src/components/cv/CVTree.tsx b/apps/webapp/src/components/cv/CVTree.tsx index 7fe5d48..4091d3e 100644 --- a/apps/webapp/src/components/cv/CVTree.tsx +++ b/apps/webapp/src/components/cv/CVTree.tsx @@ -28,7 +28,6 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: { const v = node.version; const isRoot = !v.parent_version_id; const isSelected = v.id === selectedId; - const isLeaf = node.children.length === 0; const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length]; return ( @@ -147,4 +146,3 @@ export default function CVTree({ versions, selectedVersionId, onSelect, onDelete
); } - diff --git a/apps/webapp/src/libs/api.ts b/apps/webapp/src/libs/api.ts index 4d922ea..736f4a9 100644 --- a/apps/webapp/src/libs/api.ts +++ b/apps/webapp/src/libs/api.ts @@ -51,6 +51,13 @@ export type Suggestion = { metadata_json?: { keywords?: string[]; confidence?: number } | null; }; +export type SubmissionStatus = + | 'draft' + | 'tailoring' + | 'pending_review' + | 'published' + | 'archived'; + export type Submission = { id: string; version_id: string; @@ -58,7 +65,7 @@ export type Submission = { role_title: string; job_url?: string | null; job_description?: string | null; - status: string; + status: SubmissionStatus; suggestions: Suggestion[]; created_at: string; }; @@ -159,8 +166,10 @@ export async function createSubmission( }); } -export const fetchSubmissions = (versionId: string): Promise => - req(`/api/v1/submissions?version_id=${versionId}`); +export const fetchSubmissions = (versionId?: string): Promise => { + const query = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ''; + return req(`/api/v1/submissions${query}`); +}; export const fetchSubmission = (id: string): Promise => req(`/api/v1/submissions/${id}`); @@ -189,6 +198,17 @@ export async function updateSuggestion( }); } +export async function updateSubmissionStatus( + submissionId: string, + status: SubmissionStatus, +): Promise { + return req(`/api/v1/submissions/${submissionId}/status`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ status }), + }); +} + export async function publishVersion( versionId?: string | null, submissionId?: string | null,