Files
jobtrackingapp/job-tracker-ui/src/applicationWorkspace.ts
T
cesnimda 109745edb0
CI and Deploy / test (pull_request) Failing after 2m51s
CI and Deploy / deploy (pull_request) Has been skipped
feat(jobs): add dedicated workspace page
Make /jobs/:id the canonical application workspace while preserving list state and compatibility links. Replace popup and expandable-row navigation with accessible whole-row routing and richer job details.
2026-08-15 13:33:00 +02:00

313 lines
11 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;
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" },
];
// Workspace navigation. Sections map to the Phase 5 milestones; each is added as its milestone lands
// so the workspace is always usable rather than a shell of placeholders.
export type WorkspaceSectionKey =
| "overview" | "job-details" | "analysis" | "match" | "checklist" | "cv" | "cover-letter"
| "documents" | "interview" | "timeline" | "communication";
export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string }[] = [
{ key: "overview", label: "Overview" },
{ key: "job-details", label: "Job Details" },
{ key: "analysis", label: "Analysis" },
{ key: "match", label: "Match" },
{ key: "checklist", label: "Checklist" },
{ key: "cv", label: "CV" },
{ key: "cover-letter", label: "Cover Letter" },
{ key: "documents", label: "Documents" },
{ key: "interview", label: "Interview Prep" },
{ key: "timeline", label: "Timeline" },
{ key: "communication", label: "Communication" },
];
export const workspaceSection = (value: string | null): WorkspaceSectionKey =>
WORKSPACE_SECTIONS.some((section) => section.key === value) ? value as WorkspaceSectionKey : "overview";
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;
};
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 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),
};
// 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" },
];
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),
};