feat: add dashboard success-rate tracking and submission stages

This commit is contained in:
2026-04-04 15:16:12 +02:00
parent 07fbfbbd85
commit 5facc4b7a5
7 changed files with 229 additions and 19 deletions

View File

@@ -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,

View File

@@ -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",

View File

@@ -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)

View File

@@ -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:

View File

@@ -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<SubmissionStatus, string> = {
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<string, string>)[status] ?? 'badge-draft';
return <span className={`badge ${cls}`}>{status.replace('_', ' ')}</span>;
} as Record<SubmissionStatus, string>)[status] ?? 'badge-draft';
return <span className={`badge ${cls}`}>{SUBMISSION_STATUS_LABELS[status] ?? status.replace('_', ' ')}</span>;
}
// ── 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<string | null>(null);
const [aiLoading, setAiLoading] = useState<string | null>(null);
const [statusLoading, setStatusLoading] = useState<string | null>(null);
const [aiJd, setAiJd] = useState<Record<string, string>>({});
const [suggestions, setSuggestions] = useState<Record<string, Suggestion[]>>({});
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 <div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>Loading</div>;
return (
@@ -349,6 +396,21 @@ function SubmissionsTab({
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={onNewSubmission}>+ New submission</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 8, marginBottom: 12 }}>
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: '#fff' }}>
<div className="label" style={{ marginBottom: 4 }}>Submitted</div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{submittedCount}</div>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: '#fff' }}>
<div className="label" style={{ marginBottom: 4 }}>Passed screening</div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{passedScreeningCount}</div>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: '#fff' }}>
<div className="label" style={{ marginBottom: 4 }}>Success rate</div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{successRate}%</div>
</div>
</div>
{submissions.length === 0 && (
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
No submissions yet. Create one to track a job application and get AI tailoring suggestions.
@@ -389,6 +451,20 @@ function SubmissionsTab({
</a>
)}
<div style={{ marginBottom: 12 }}>
<div className="label" style={{ marginBottom: 6 }}>Application stage</div>
<select
value={s.status}
onChange={e => changeStatus(s, e.target.value as SubmissionStatus)}
disabled={statusLoading === s.id}
style={{ maxWidth: 280 }}
>
{SUBMISSION_STATUS_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
{/* AI tailoring */}
<div style={{ marginBottom: 12 }}>
<div className="label" style={{ marginBottom: 6 }}>AI tailoring</div>
@@ -485,6 +561,7 @@ export default function Dashboard() {
const [recentlyPublishedSlug, setRecentlyPublishedSlug] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('content');
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [allSubmissions, setAllSubmissions] = useState<Submission[]>([]);
const [subsLoading, setSubsLoading] = useState(false);
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(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 && (
<div style={{ padding: '16px 20px 0', flexShrink: 0 }}>
<div style={{ border: '1px solid var(--border)', borderRadius: 8, background: '#fff', padding: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<div style={{ minWidth: 0 }}>
<div className="label" style={{ marginBottom: 3 }}>Dashboard overview</div>
<div style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{selectedDoc.title}
</div>
</div>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{selectedDoc.versions.length} version{selectedDoc.versions.length !== 1 ? 's' : ''}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 8, marginBottom: 10 }}>
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: 'var(--surface)' }}>
<div className="label" style={{ marginBottom: 3 }}>Submissions</div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{selectedDocSubmissions.length}</div>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: 'var(--surface)' }}>
<div className="label" style={{ marginBottom: 3 }}>Submitted</div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{selectedDocSubmittedCount}</div>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: 'var(--surface)' }}>
<div className="label" style={{ marginBottom: 3 }}>Passed screening</div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{selectedDocPassedScreeningCount}</div>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px', background: 'var(--surface)' }}>
<div className="label" style={{ marginBottom: 3 }}>Success rate</div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{selectedDocSuccessRate}%</div>
</div>
</div>
<div style={{ border: '1px solid var(--border)', borderRadius: 6, background: 'var(--surface)', maxHeight: 220, overflow: 'auto' }}>
<div className="label" style={{ padding: '8px 10px 4px' }}>Full branch tree</div>
<CVTree
versions={selectedDoc.versions}
selectedVersionId={selectedVersionId}
onSelect={selectVersion}
/>
</div>
</div>
</div>
)}
{/* version header */}
<div style={{ padding: '16px 20px 0', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
@@ -934,9 +1073,9 @@ export default function Dashboard() {
<SubmissionsTab
submissions={submissions}
loading={subsLoading}
versionId={selectedVersionId!}
onNewSubmission={() => setModal('submission')}
onRefresh={refreshSubs}
onStatusChange={handleSubmissionStatusChange}
/>
)}
</div>

View File

@@ -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
</div>
);
}

View File

@@ -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<Submission[]> =>
req<Submission[]>(`/api/v1/submissions?version_id=${versionId}`);
export const fetchSubmissions = (versionId?: string): Promise<Submission[]> => {
const query = versionId ? `?version_id=${encodeURIComponent(versionId)}` : '';
return req<Submission[]>(`/api/v1/submissions${query}`);
};
export const fetchSubmission = (id: string): Promise<Submission> =>
req<Submission>(`/api/v1/submissions/${id}`);
@@ -189,6 +198,17 @@ export async function updateSuggestion(
});
}
export async function updateSubmissionStatus(
submissionId: string,
status: SubmissionStatus,
): Promise<Submission> {
return req<Submission>(`/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,