mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 08:43:37 +00:00
feat: add dashboard success-rate tracking and submission stages
This commit is contained in:
@@ -8,6 +8,7 @@ from app.schemas import (
|
|||||||
AiSuggestionRequest,
|
AiSuggestionRequest,
|
||||||
SubmissionCreateRequest,
|
SubmissionCreateRequest,
|
||||||
SubmissionResponse,
|
SubmissionResponse,
|
||||||
|
SubmissionStatusUpdateRequest,
|
||||||
SuggestionResponse,
|
SuggestionResponse,
|
||||||
SuggestionUpdateRequest,
|
SuggestionUpdateRequest,
|
||||||
)
|
)
|
||||||
@@ -16,8 +17,10 @@ from app.services.submissions import (
|
|||||||
get_submission,
|
get_submission,
|
||||||
list_submissions,
|
list_submissions,
|
||||||
request_ai_suggestions,
|
request_ai_suggestions,
|
||||||
|
update_submission_status,
|
||||||
update_suggestion,
|
update_suggestion,
|
||||||
)
|
)
|
||||||
|
from app.models import SubmissionStatus
|
||||||
from dlib.auth import AuthenticatedUser
|
from dlib.auth import AuthenticatedUser
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +43,9 @@ async def get_submission_endpoint(
|
|||||||
session: AsyncSession = Depends(get_db),
|
session: AsyncSession = Depends(get_db),
|
||||||
user: AuthenticatedUser = Depends(get_current_user),
|
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:
|
if not submission:
|
||||||
raise HTTPException(status_code=404, detail="Submission not found")
|
raise HTTPException(status_code=404, detail="Submission not found")
|
||||||
return SubmissionResponse.model_validate(submission)
|
return SubmissionResponse.model_validate(submission)
|
||||||
@@ -66,6 +71,24 @@ async def create_submission_endpoint(
|
|||||||
return SubmissionResponse.model_validate(submission)
|
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])
|
@router.post("/{submission_id}/ai", response_model=list[SuggestionResponse])
|
||||||
async def request_submissions_ai(
|
async def request_submissions_ai(
|
||||||
submission_id: str,
|
submission_id: str,
|
||||||
@@ -85,7 +108,9 @@ async def request_submissions_ai(
|
|||||||
return [SuggestionResponse.model_validate(item) for item in suggestions]
|
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(
|
async def update_suggestion_endpoint(
|
||||||
submission_id: str,
|
submission_id: str,
|
||||||
suggestion_id: str,
|
suggestion_id: str,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from .cv import (
|
|||||||
PublishRequest,
|
PublishRequest,
|
||||||
SubmissionCreateRequest,
|
SubmissionCreateRequest,
|
||||||
SubmissionResponse,
|
SubmissionResponse,
|
||||||
|
SubmissionStatusUpdateRequest,
|
||||||
SuggestionResponse,
|
SuggestionResponse,
|
||||||
SuggestionUpdateRequest,
|
SuggestionUpdateRequest,
|
||||||
VersionResponse,
|
VersionResponse,
|
||||||
@@ -25,6 +26,7 @@ __all__ = [
|
|||||||
"PatchApplyRequest",
|
"PatchApplyRequest",
|
||||||
"SubmissionCreateRequest",
|
"SubmissionCreateRequest",
|
||||||
"SubmissionResponse",
|
"SubmissionResponse",
|
||||||
|
"SubmissionStatusUpdateRequest",
|
||||||
"AiSuggestionRequest",
|
"AiSuggestionRequest",
|
||||||
"SuggestionResponse",
|
"SuggestionResponse",
|
||||||
"SuggestionUpdateRequest",
|
"SuggestionUpdateRequest",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@@ -90,6 +90,16 @@ class SubmissionCreateRequest(BaseModel):
|
|||||||
job_description: str | None = None
|
job_description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionStatusUpdateRequest(BaseModel):
|
||||||
|
status: Literal[
|
||||||
|
"draft",
|
||||||
|
"tailoring",
|
||||||
|
"pending_review",
|
||||||
|
"published",
|
||||||
|
"archived",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SuggestionResponse(BaseModel):
|
class SuggestionResponse(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,22 @@ async def update_suggestion(
|
|||||||
return 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(
|
async def _get_version_for_owner(
|
||||||
session: AsyncSession, owner_id: str, version_id: str
|
session: AsyncSession, owner_id: str, version_id: str
|
||||||
) -> CvVersion | None:
|
) -> CvVersion | None:
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ import {
|
|||||||
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
|
||||||
publishVersion, PublicAsset, PublicAssetAnalytics,
|
publishVersion, PublicAsset, PublicAssetAnalytics,
|
||||||
requestAiSuggestions,
|
requestAiSuggestions,
|
||||||
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
|
Submission,
|
||||||
|
SubmissionStatus,
|
||||||
|
StructuredBlock,
|
||||||
|
Suggestion,
|
||||||
|
updateSubmissionStatus,
|
||||||
|
updateSuggestion,
|
||||||
|
uploadDocument,
|
||||||
|
Version,
|
||||||
} from '@/libs/api';
|
} from '@/libs/api';
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -20,12 +27,32 @@ function fmt(iso: string) {
|
|||||||
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
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 = ({
|
const cls = ({
|
||||||
draft: 'badge-draft', tailoring: 'badge-submitted', pending_review: 'badge-interviewing',
|
draft: 'badge-draft', tailoring: 'badge-submitted', pending_review: 'badge-interviewing',
|
||||||
published: 'badge-public', archived: 'badge-closed',
|
published: 'badge-public', archived: 'badge-closed',
|
||||||
} as Record<string, string>)[status] ?? 'badge-draft';
|
} as Record<SubmissionStatus, string>)[status] ?? 'badge-draft';
|
||||||
return <span className={`badge ${cls}`}>{status.replace('_', ' ')}</span>;
|
return <span className={`badge ${cls}`}>{SUBMISSION_STATUS_LABELS[status] ?? status.replace('_', ' ')}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── modals ────────────────────────────────────────────────────────────────────
|
// ── modals ────────────────────────────────────────────────────────────────────
|
||||||
@@ -304,20 +331,27 @@ function ContentTab({
|
|||||||
// ── submissions tab ───────────────────────────────────────────────────────────
|
// ── submissions tab ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SubmissionsTab({
|
function SubmissionsTab({
|
||||||
submissions, loading, versionId,
|
submissions, loading,
|
||||||
onNewSubmission, onRefresh,
|
onNewSubmission, onRefresh, onStatusChange,
|
||||||
}: {
|
}: {
|
||||||
submissions: Submission[];
|
submissions: Submission[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
versionId: string;
|
|
||||||
onNewSubmission: () => void;
|
onNewSubmission: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onStatusChange: (submissionId: string, status: SubmissionStatus) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
const [aiLoading, setAiLoading] = 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 [aiJd, setAiJd] = useState<Record<string, string>>({});
|
||||||
const [suggestions, setSuggestions] = useState<Record<string, Suggestion[]>>({});
|
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 loadAi = async (s: Submission) => {
|
||||||
const jd = aiJd[s.id] ?? s.job_description ?? '';
|
const jd = aiJd[s.id] ?? s.job_description ?? '';
|
||||||
if (!jd.trim()) return;
|
if (!jd.trim()) return;
|
||||||
@@ -340,6 +374,19 @@ function SubmissionsTab({
|
|||||||
} catch { /* ignore */ }
|
} 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>;
|
if (loading) return <div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>Loading…</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -349,6 +396,21 @@ function SubmissionsTab({
|
|||||||
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={onNewSubmission}>+ New submission</button>
|
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={onNewSubmission}>+ New submission</button>
|
||||||
</div>
|
</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 && (
|
{submissions.length === 0 && (
|
||||||
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
|
<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.
|
No submissions yet. Create one to track a job application and get AI tailoring suggestions.
|
||||||
@@ -389,6 +451,20 @@ function SubmissionsTab({
|
|||||||
</a>
|
</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 */}
|
{/* AI tailoring */}
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<div className="label" style={{ marginBottom: 6 }}>AI tailoring</div>
|
<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 [recentlyPublishedSlug, setRecentlyPublishedSlug] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('content');
|
const [activeTab, setActiveTab] = useState<Tab>('content');
|
||||||
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
||||||
|
const [allSubmissions, setAllSubmissions] = useState<Submission[]>([]);
|
||||||
const [subsLoading, setSubsLoading] = useState(false);
|
const [subsLoading, setSubsLoading] = useState(false);
|
||||||
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
|
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
@@ -493,9 +570,10 @@ export default function Dashboard() {
|
|||||||
const [applyError, setApplyError] = useState('');
|
const [applyError, setApplyError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDocuments()
|
Promise.all([fetchDocuments(), fetchSubmissions().catch(() => [])])
|
||||||
.then(d => {
|
.then(([d, allSubs]) => {
|
||||||
setDocs(d);
|
setDocs(d);
|
||||||
|
setAllSubmissions(allSubs);
|
||||||
if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); }
|
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.'))
|
.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 selectedDoc = docs.find(d => d.id === selectedDocId) ?? null;
|
||||||
const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? 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 publishedAssets = selectedVersion?.public_assets ?? [];
|
||||||
const sortedPublishedAssets = [...publishedAssets].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
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(/\/$/, '');
|
const publicBaseUrl = (process.env.NEXT_PUBLIC_BASE_URL ?? '').replace(/\/$/, '');
|
||||||
@@ -543,6 +628,7 @@ export default function Dashboard() {
|
|||||||
const refreshDocs = async () => {
|
const refreshDocs = async () => {
|
||||||
const fresh = await fetchDocuments().catch(() => docs);
|
const fresh = await fetchDocuments().catch(() => docs);
|
||||||
setDocs(fresh);
|
setDocs(fresh);
|
||||||
|
refreshAllSubs();
|
||||||
return fresh;
|
return fresh;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -551,6 +637,10 @@ export default function Dashboard() {
|
|||||||
fetchSubmissions(selectedVersionId).then(setSubmissions).catch(() => { });
|
fetchSubmissions(selectedVersionId).then(setSubmissions).catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function refreshAllSubs() {
|
||||||
|
fetchSubmissions().then(setAllSubmissions).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
const onUploadDone = (doc: Document) => {
|
const onUploadDone = (doc: Document) => {
|
||||||
setDocs(prev => [doc, ...prev.filter(d => d.id !== doc.id)]);
|
setDocs(prev => [doc, ...prev.filter(d => d.id !== doc.id)]);
|
||||||
setSelectedDocId(doc.id);
|
setSelectedDocId(doc.id);
|
||||||
@@ -569,10 +659,16 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const onSubmissionDone = (s: Submission) => {
|
const onSubmissionDone = (s: Submission) => {
|
||||||
setSubmissions(prev => [s, ...prev]);
|
setSubmissions(prev => [s, ...prev]);
|
||||||
|
setAllSubmissions(prev => [s, ...prev]);
|
||||||
setModal(null);
|
setModal(null);
|
||||||
setActiveTab('submissions');
|
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) => {
|
const stageEdit = (path: string, old_value: string, new_value: string) => {
|
||||||
setPendingEdits(prev => new Map(prev).set(path, { old_value, new_value }));
|
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 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 hasChildren = selectedDoc?.versions.some(v => v.parent_version_id === versionId);
|
||||||
const msg = hasChildren
|
const msg = hasChildren
|
||||||
? 'Delete this branch and all its sub-branches? This cannot be undone.'
|
? 'Delete this branch and all its sub-branches? This cannot be undone.'
|
||||||
@@ -767,6 +862,50 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{selectedVersion && (
|
{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 */}
|
{/* version header */}
|
||||||
<div style={{ padding: '16px 20px 0', flexShrink: 0 }}>
|
<div style={{ padding: '16px 20px 0', flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
|
||||||
@@ -934,9 +1073,9 @@ export default function Dashboard() {
|
|||||||
<SubmissionsTab
|
<SubmissionsTab
|
||||||
submissions={submissions}
|
submissions={submissions}
|
||||||
loading={subsLoading}
|
loading={subsLoading}
|
||||||
versionId={selectedVersionId!}
|
|
||||||
onNewSubmission={() => setModal('submission')}
|
onNewSubmission={() => setModal('submission')}
|
||||||
onRefresh={refreshSubs}
|
onRefresh={refreshSubs}
|
||||||
|
onStatusChange={handleSubmissionStatusChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
|||||||
const v = node.version;
|
const v = node.version;
|
||||||
const isRoot = !v.parent_version_id;
|
const isRoot = !v.parent_version_id;
|
||||||
const isSelected = v.id === selectedId;
|
const isSelected = v.id === selectedId;
|
||||||
const isLeaf = node.children.length === 0;
|
|
||||||
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
|
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -147,4 +146,3 @@ export default function CVTree({ versions, selectedVersionId, onSelect, onDelete
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ export type Suggestion = {
|
|||||||
metadata_json?: { keywords?: string[]; confidence?: number } | null;
|
metadata_json?: { keywords?: string[]; confidence?: number } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SubmissionStatus =
|
||||||
|
| 'draft'
|
||||||
|
| 'tailoring'
|
||||||
|
| 'pending_review'
|
||||||
|
| 'published'
|
||||||
|
| 'archived';
|
||||||
|
|
||||||
export type Submission = {
|
export type Submission = {
|
||||||
id: string;
|
id: string;
|
||||||
version_id: string;
|
version_id: string;
|
||||||
@@ -58,7 +65,7 @@ export type Submission = {
|
|||||||
role_title: string;
|
role_title: string;
|
||||||
job_url?: string | null;
|
job_url?: string | null;
|
||||||
job_description?: string | null;
|
job_description?: string | null;
|
||||||
status: string;
|
status: SubmissionStatus;
|
||||||
suggestions: Suggestion[];
|
suggestions: Suggestion[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
@@ -159,8 +166,10 @@ export async function createSubmission(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchSubmissions = (versionId: string): Promise<Submission[]> =>
|
export const fetchSubmissions = (versionId?: string): Promise<Submission[]> => {
|
||||||
req<Submission[]>(`/api/v1/submissions?version_id=${versionId}`);
|
const query = versionId ? `?version_id=${encodeURIComponent(versionId)}` : '';
|
||||||
|
return req<Submission[]>(`/api/v1/submissions${query}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchSubmission = (id: string): Promise<Submission> =>
|
export const fetchSubmission = (id: string): Promise<Submission> =>
|
||||||
req<Submission>(`/api/v1/submissions/${id}`);
|
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(
|
export async function publishVersion(
|
||||||
versionId?: string | null,
|
versionId?: string | null,
|
||||||
submissionId?: string | null,
|
submissionId?: string | null,
|
||||||
|
|||||||
Reference in New Issue
Block a user