382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
import { api } from "./api";
|
|
|
|
// Mirrors WorkspaceOverviewDto. Read-only aggregate — the workspace owns no data.
|
|
export type WorkspaceCv = {
|
|
variantId: number | null;
|
|
variantName: string | null;
|
|
themeId: string | null;
|
|
hasTailoredCvText: boolean;
|
|
updatedAtUtc: string | null;
|
|
};
|
|
|
|
export type WorkspaceActivity = { type: string; detail: string | null; at: string };
|
|
export type WorkspaceNextStep = { key: string; label: string; reason: string; section: string | null };
|
|
|
|
export type WorkspaceOverview = {
|
|
id: number;
|
|
jobTitle: string;
|
|
company: string | null;
|
|
location: string | null;
|
|
salary: string | null;
|
|
status: string;
|
|
stageGroup: string;
|
|
stageOrder: number;
|
|
dateApplied: string | null;
|
|
deadline: string | null;
|
|
followUpAt: string | null;
|
|
nextAction: string | null;
|
|
jobUrl: string | null;
|
|
savedAt: string;
|
|
description: string | null;
|
|
translatedDescription: string | null;
|
|
descriptionLanguage: string | null;
|
|
tags: string[];
|
|
notes: string | null;
|
|
applicationAnswerDraft: string | null;
|
|
recruiterMessageDraft: string | null;
|
|
source: string | null;
|
|
countryCode: string | null;
|
|
hasJobDescription: boolean;
|
|
cv: WorkspaceCv;
|
|
hasCoverLetter: boolean;
|
|
documentCount: number;
|
|
hasPortfolio: boolean;
|
|
aiInteractionCount: number;
|
|
lastAiAtUtc: string | null;
|
|
recentActivity: WorkspaceActivity[];
|
|
nextStep: WorkspaceNextStep | null;
|
|
checklistProgress: ChecklistProgress | null;
|
|
};
|
|
|
|
// Milestone 2 — the application checklist. One workflow surface: system items seed from the same
|
|
// readiness signals the backend already computed, and the user owns everything after that.
|
|
export type ChecklistStatus = "pending" | "done" | "dismissed";
|
|
|
|
export type ChecklistItem = {
|
|
id: number;
|
|
systemKey: string | null;
|
|
title: string;
|
|
description: string | null;
|
|
category: string;
|
|
status: ChecklistStatus;
|
|
section: string | null;
|
|
sortOrder: number;
|
|
isSystemGenerated: boolean;
|
|
isAutoCompleted: boolean;
|
|
completedAt: string | null;
|
|
};
|
|
|
|
export type ChecklistProgress = { total: number; completed: number; dismissed: number; percent: number };
|
|
export type Checklist = { items: ChecklistItem[]; progress: ChecklistProgress };
|
|
|
|
export const CHECKLIST_CATEGORIES: { key: string; label: string }[] = [
|
|
{ key: "preparation", label: "Before applying" },
|
|
{ key: "submission", label: "Submitting" },
|
|
{ key: "follow-up", label: "Follow-up" },
|
|
{ key: "interview", label: "Interview" },
|
|
{ key: "custom", label: "Your own tasks" },
|
|
];
|
|
|
|
// URL-addressable presentation; domain components remain the data owners.
|
|
export type WorkspaceSectionKey = "overview" | "match" | "application" | "documents" | "timeline" | "tasks" | "interview";
|
|
|
|
export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string }[] = [
|
|
{ key: "overview", label: "Overview" },
|
|
{ key: "match", label: "Match" },
|
|
{ key: "application", label: "Application" },
|
|
{ key: "documents", label: "Documents" },
|
|
{ key: "timeline", label: "Timeline" },
|
|
{ key: "tasks", label: "Notes & tasks" },
|
|
{ key: "interview", label: "Interview Prep" },
|
|
];
|
|
|
|
export function checklistSystemTranslationToken(systemKey?: string | null): string | null {
|
|
if (!systemKey || systemKey.startsWith("learning:")) return null;
|
|
return systemKey.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
|
}
|
|
|
|
const LEGACY_WORKSPACE_SECTIONS: Record<string, WorkspaceSectionKey> = {
|
|
analysis: "match",
|
|
cv: "application",
|
|
"cover-letter": "application",
|
|
"job-details": "overview",
|
|
checklist: "tasks",
|
|
communication: "timeline",
|
|
portfolio: "documents",
|
|
notes: "tasks",
|
|
};
|
|
|
|
/** Preserve old shared/bookmarked workspace links while keeping one clear primary navigation. */
|
|
export const workspaceSection = (value: string | null): WorkspaceSectionKey => {
|
|
if (value && Object.prototype.hasOwnProperty.call(LEGACY_WORKSPACE_SECTIONS, value)) return LEGACY_WORKSPACE_SECTIONS[value];
|
|
return WORKSPACE_SECTIONS.some((section) => section.key === value) ? value as WorkspaceSectionKey : "overview";
|
|
};
|
|
|
|
/** Legacy links select their original content, even when unrelated sub metadata is present. */
|
|
export function workspaceSubsection(section: string | null, sub: string | null): string {
|
|
if (section === "cv") return "cv";
|
|
if (section === "cover-letter") return "letter";
|
|
if (section === "communication") return "correspondence";
|
|
if (workspaceSection(section) === "application") return ["cv", "letter", "answers", "submission"].includes(sub ?? "") ? sub! : "cv";
|
|
return sub === "correspondence" ? "correspondence" : "activity";
|
|
}
|
|
|
|
export const applicationWorkspaceApi = {
|
|
overview: (jobId: number) =>
|
|
api.get<WorkspaceOverview>(`/jobapplications/${jobId}/workspace`).then((r) => r.data),
|
|
};
|
|
|
|
// Phase 5.3 — Application Intelligence. All three reads are deterministic and read-only; the AI
|
|
// narrative stays on the existing /ai routes, which require the user to ask for it.
|
|
export type TimelineEvent = {
|
|
id: number;
|
|
type: string;
|
|
category: string;
|
|
summary: string;
|
|
detail: string | null;
|
|
isMilestone: boolean;
|
|
at: string;
|
|
};
|
|
|
|
export type TimelineDay = { date: string; label: string; events: TimelineEvent[] };
|
|
|
|
export type Timeline = {
|
|
days: TimelineDay[];
|
|
milestones: TimelineEvent[];
|
|
categories: string[];
|
|
totalEvents: number;
|
|
};
|
|
|
|
export type JobAnalysis = {
|
|
role: string | null;
|
|
company: string | null;
|
|
location: string | null;
|
|
employmentType: string | null;
|
|
seniority: string | null;
|
|
salary: string | null;
|
|
technologies: string[];
|
|
skills: string[];
|
|
responsibilities: string[];
|
|
keywords: string[];
|
|
summary: string;
|
|
importantRequirements: string[];
|
|
interviewTopics: string[];
|
|
missingInformation: string[];
|
|
hasJobDescription: boolean;
|
|
aiSuggestionCount: number;
|
|
};
|
|
|
|
export type MatchEvidence = { title: string; subtitle: string | null; matched: string[] };
|
|
|
|
export type CareerMatch = {
|
|
score: number;
|
|
band: string;
|
|
hasEnoughSignal: boolean;
|
|
hasCareerProfile: boolean;
|
|
matchedSkills: string[];
|
|
missingSkills: string[];
|
|
relevantExperience: MatchEvidence[];
|
|
relevantProjects: MatchEvidence[];
|
|
suggestions: string[];
|
|
aiSuggestionCount: number;
|
|
hasSelectedCv: boolean;
|
|
selectedCvName: string | null;
|
|
jobLanguage: "en" | "nb";
|
|
cvLanguage: "en" | "nb" | null;
|
|
languageMismatch: boolean;
|
|
usedTranslatedJobDescription: boolean;
|
|
};
|
|
|
|
export const TIMELINE_CATEGORY_LABELS: Record<string, string> = {
|
|
lifecycle: "Lifecycle",
|
|
stage: "Stage",
|
|
"follow-up": "Follow-up",
|
|
communication: "Communication",
|
|
ai: "AI",
|
|
};
|
|
|
|
export const applicationIntelligenceApi = {
|
|
timeline: (jobId: number, category?: string, milestonesOnly?: boolean) =>
|
|
api
|
|
.get<Timeline>(`/jobapplications/${jobId}/timeline`, {
|
|
params: { category: category || undefined, milestonesOnly: milestonesOnly || undefined },
|
|
})
|
|
.then((r) => r.data),
|
|
analysis: (jobId: number) =>
|
|
api.get<JobAnalysis>(`/jobapplications/${jobId}/analysis`).then((r) => r.data),
|
|
match: (jobId: number) => api.get<CareerMatch>(`/jobapplications/${jobId}/match`).then((r) => r.data),
|
|
};
|
|
|
|
// Phase 5.4 — Application Assets. CV variant CRUD, preview, PDF export and version history stay on
|
|
// /api/cv (the existing CV builder). These types cover only what is application-scoped.
|
|
export type CvVariantSummary = {
|
|
id: number;
|
|
name: string;
|
|
themeId: string;
|
|
publicSlug: string;
|
|
isPublic: boolean;
|
|
version: number;
|
|
jobApplicationId: number | null;
|
|
updatedAtUtc: string;
|
|
};
|
|
|
|
export type ApplicationCv = {
|
|
attachedVariantId: number | null;
|
|
attachedVariantName: string | null;
|
|
attachedThemeId: string | null;
|
|
attachedVersion: number | null;
|
|
attachedUpdatedAtUtc: string | null;
|
|
attachedIsPublic: boolean;
|
|
hasTailoredCvText: boolean;
|
|
availableVariants: CvVariantSummary[];
|
|
};
|
|
|
|
export type TailoringSuggestion = { kind: string; title: string; detail: string | null; items: string[] };
|
|
|
|
export type TailoringPlan = {
|
|
hasJobDescription: boolean;
|
|
hasCareerProfile: boolean;
|
|
hasAttachedVariant: boolean;
|
|
matchScore: number;
|
|
suggestions: TailoringSuggestion[];
|
|
aiSuggestionCount: number;
|
|
};
|
|
|
|
export type CoverLetterVersion = {
|
|
version: number;
|
|
source: string;
|
|
aiAction: string | null;
|
|
length: number;
|
|
createdAtUtc: string;
|
|
isCurrent: boolean;
|
|
};
|
|
|
|
export type CoverLetter = {
|
|
text: string | null;
|
|
currentVersion: number;
|
|
versions: CoverLetterVersion[];
|
|
aiSuggestionCount: number;
|
|
};
|
|
|
|
export type SubmittedPackageAttachment = {
|
|
id: number;
|
|
fileName: string;
|
|
fileType: string;
|
|
purpose: string | null;
|
|
fileSize: number;
|
|
sha256: string;
|
|
};
|
|
|
|
export type SubmittedPackage = {
|
|
id: number;
|
|
version: number;
|
|
cvVariantId: number | null;
|
|
cvVariantName: string | null;
|
|
cvVariantVersion: number | null;
|
|
cvThemeId: string | null;
|
|
hasCv: boolean;
|
|
hasCoverLetter: boolean;
|
|
createdAtUtc: string;
|
|
attachments: SubmittedPackageAttachment[];
|
|
};
|
|
|
|
export type SubmittedPackageDetail = {
|
|
summary: SubmittedPackage;
|
|
renderedCvHtml: string | null;
|
|
coverLetterText: string | null;
|
|
applicationMaterialJson: string;
|
|
};
|
|
|
|
export const applicationAssetsApi = {
|
|
cv: (jobId: number) => api.get<ApplicationCv>(`/jobapplications/${jobId}/cv`).then((r) => r.data),
|
|
attachVariant: (jobId: number, variantId: number | null) =>
|
|
api.put<ApplicationCv>(`/jobapplications/${jobId}/cv`, { variantId }).then((r) => r.data),
|
|
tailoring: (jobId: number) =>
|
|
api.get<TailoringPlan>(`/jobapplications/${jobId}/tailoring`).then((r) => r.data),
|
|
coverLetter: (jobId: number) =>
|
|
api.get<CoverLetter>(`/jobapplications/${jobId}/cover-letter`).then((r) => r.data),
|
|
saveCoverLetter: (jobId: number, text: string, source = "manual", aiAction?: string) =>
|
|
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data),
|
|
restoreCoverLetter: (jobId: number, version: number) =>
|
|
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data),
|
|
submittedPackages: (jobId: number) =>
|
|
api.get<SubmittedPackage[]>(`/jobapplications/${jobId}/submitted-packages`).then((r) => r.data),
|
|
captureSubmittedPackage: (jobId: number) =>
|
|
api.post<SubmittedPackage>(`/jobapplications/${jobId}/submitted-packages`).then((r) => r.data),
|
|
submittedPackage: (jobId: number, packageId: number) =>
|
|
api.get<SubmittedPackageDetail>(`/jobapplications/${jobId}/submitted-packages/${packageId}`).then((r) => r.data),
|
|
downloadSubmittedAttachment: (jobId: number, packageId: number, attachmentId: number) =>
|
|
api.get<Blob>(`/jobapplications/${jobId}/submitted-packages/${packageId}/attachments/${attachmentId}`, { responseType: "blob" }).then((r) => r.data),
|
|
saveApplicationDrafts: (jobId: number, applicationAnswerDraft: string, recruiterMessageDraft: string) =>
|
|
api.put(`/jobapplications/${jobId}/application-drafts`, { applicationAnswerDraft, recruiterMessageDraft }).then(() => undefined),
|
|
};
|
|
|
|
// Phase 5.5 — Interview preparation and follow-up. Prep content is the user's; AI suggestions come
|
|
// from the existing /ai routes and only land here once accepted.
|
|
export type InterviewPrepItem = {
|
|
id: number;
|
|
category: string;
|
|
title: string;
|
|
content: string | null;
|
|
source: string;
|
|
isPrepared: boolean;
|
|
sortOrder: number;
|
|
updatedAtUtc: string;
|
|
};
|
|
|
|
export type InterviewPrepGroup = { category: string; label: string; items: InterviewPrepItem[] };
|
|
|
|
export type InterviewPrepBoard = {
|
|
groups: InterviewPrepGroup[];
|
|
total: number;
|
|
prepared: number;
|
|
percent: number;
|
|
isInterviewStage: boolean;
|
|
aiSuggestionCount: number;
|
|
};
|
|
|
|
export type FollowUp = {
|
|
followUpAt: string | null;
|
|
nextAction: string | null;
|
|
responseReceived: boolean;
|
|
openFollowUpTasks: number;
|
|
};
|
|
|
|
export const INTERVIEW_PREP_CATEGORIES: { key: string; label: string }[] = [
|
|
{ key: "company-research", label: "Company research" },
|
|
{ key: "technical", label: "Technical preparation" },
|
|
{ key: "behavioural", label: "Behavioural questions" },
|
|
{ key: "star", label: "STAR examples" },
|
|
{ key: "question", label: "Questions to ask them" },
|
|
{ key: "note", label: "Notes" },
|
|
{ key: "debrief", label: "Interview debrief" },
|
|
];
|
|
|
|
export const interviewPrepApi = {
|
|
get: (jobId: number) =>
|
|
api.get<InterviewPrepBoard>(`/jobapplications/${jobId}/interview-prep`).then((r) => r.data),
|
|
add: (jobId: number, body: { category?: string; title: string; content?: string; source?: string }) =>
|
|
api.post<InterviewPrepItem>(`/jobapplications/${jobId}/interview-prep`, body).then((r) => r.data),
|
|
update: (jobId: number, itemId: number, body: Partial<Pick<InterviewPrepItem, "title" | "content" | "category" | "isPrepared">>) =>
|
|
api.patch<InterviewPrepItem>(`/jobapplications/${jobId}/interview-prep/${itemId}`, body).then((r) => r.data),
|
|
remove: (jobId: number, itemId: number) =>
|
|
api.delete(`/jobapplications/${jobId}/interview-prep/${itemId}`).then(() => undefined),
|
|
followUp: (jobId: number) =>
|
|
api.get<FollowUp>(`/jobapplications/${jobId}/follow-up`).then((r) => r.data),
|
|
setFollowUp: (jobId: number, followUpAt: string | null, nextAction?: string | null) =>
|
|
api.put<FollowUp>(`/jobapplications/${jobId}/follow-up`, { followUpAt, nextAction }).then((r) => r.data),
|
|
};
|
|
|
|
export const applicationChecklistApi = {
|
|
get: (jobId: number) =>
|
|
api.get<Checklist>(`/jobapplications/${jobId}/checklist`).then((r) => r.data),
|
|
add: (jobId: number, title: string, description?: string, category?: string) =>
|
|
api.post<ChecklistItem>(`/jobapplications/${jobId}/checklist`, { title, description, category }).then((r) => r.data),
|
|
update: (jobId: number, itemId: number, patch: Partial<Pick<ChecklistItem, "title" | "description" | "category" | "status">>) =>
|
|
api.patch<ChecklistItem>(`/jobapplications/${jobId}/checklist/${itemId}`, patch).then((r) => r.data),
|
|
remove: (jobId: number, itemId: number) =>
|
|
api.delete(`/jobapplications/${jobId}/checklist/${itemId}`).then(() => undefined),
|
|
reorder: (jobId: number, orderedIds: number[]) =>
|
|
api.put<Checklist>(`/jobapplications/${jobId}/checklist/order`, orderedIds).then((r) => r.data),
|
|
};
|