From d3fda190271b5e079d92042244fd539c35fee2cc Mon Sep 17 00:00:00 2001 From: Roman Mazuryk Date: Thu, 21 May 2026 22:56:17 +0200 Subject: [PATCH] feat(job-os): add archive reset workflow --- ...man_Target_Companies_JobSprint_Import.xlsx | Bin 0 -> 12451 bytes .../job-os-companies-import-template.csv | 4 - docs/assets/jobsprint_top25_companies.csv | 26 -- ...oman-target-companies-jobsprint-import.csv | 41 ++ .../components/job-os/JobOsPipelineBoard.tsx | 5 + src/app/hooks/useJobOs.ts | 34 +- src/app/pages/dashboard/DashboardPage.tsx | 34 +- .../pages/job-os/JobOsApplicationsPage.tsx | 395 +++++++++++++++--- src/app/pages/job-os/JobOsCompaniesPage.tsx | 315 ++++++++++---- src/app/pages/job-os/JobOsOutreachPage.tsx | 97 +++-- src/app/pages/job-os/JobOsRolesPage.tsx | 288 ++++++++----- .../services/execution/nextActionEngine.ts | 80 +++- src/app/services/jobOsApplications.ts | 34 ++ src/app/services/jobOsArchive.ts | 35 ++ src/app/services/jobOsState.ts | 24 +- src/app/types/jobOs.ts | 12 + 16 files changed, 1067 insertions(+), 357 deletions(-) create mode 100644 docs/assets/Roman_Target_Companies_JobSprint_Import.xlsx delete mode 100644 docs/assets/job-os-companies-import-template.csv delete mode 100644 docs/assets/jobsprint_top25_companies.csv create mode 100644 public/templates/roman-target-companies-jobsprint-import.csv create mode 100644 src/app/services/jobOsArchive.ts diff --git a/docs/assets/Roman_Target_Companies_JobSprint_Import.xlsx b/docs/assets/Roman_Target_Companies_JobSprint_Import.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ff7cb71c8fa7e7abce840fa31f70d38f4579a114 GIT binary patch literal 12451 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 application.roleId === roleId && !isArchived(application) + ); +} + function isInterviewStageStatus(status: ApplicationStatus): boolean { return status === "interview" || status === "final" || status === "offer"; } @@ -498,6 +508,9 @@ export function useJobOs(userId: string | null): UseJobOsReturn { roles: prev.roles, applications: prev.applications, outreach: prev.outreach, + cvProfiles: prev.cvProfiles, + jobDescriptions: prev.jobDescriptions, + cvTailoringRuns: prev.cvTailoringRuns, }) : prev; const next: JobOsState = { @@ -1255,7 +1268,7 @@ export function useJobOs(userId: string | null): UseJobOsReturn { const syncedApplicationStatus = updates.status ? mapRoleStatusToApplicationStatus(updates.status) : null; const linkedApplications = state.applications.filter( - (application) => application.roleId === id + (application) => application.roleId === id && !isArchived(application) ); await mutate( @@ -1331,7 +1344,7 @@ export function useJobOs(userId: string | null): UseJobOsReturn { const roleId = payload.roleId?.trim(); if ( roleId && - state.applications.some((application) => application.roleId === roleId) + hasOpenApplicationForRole(state.applications, roleId) ) { throw new Error("An application for this role already exists."); } @@ -1385,6 +1398,13 @@ export function useJobOs(userId: string | null): UseJobOsReturn { const linkedRoleId = targetApplication?.roleId ?? updates.roleId; const syncedRoleStatus = updates.status ? mapApplicationStatusToRoleStatus(updates.status) : null; + const archiveSyncedRoleStatus = + updates.archived === true + ? "closed" + : updates.archived === false && targetApplication + ? mapApplicationStatusToRoleStatus(updates.status ?? targetApplication.status) + : null; + const nextRoleStatus = archiveSyncedRoleStatus ?? syncedRoleStatus; const hasExplicitInterviewMarker = typeof updates.interviewStageReached === "boolean"; const shouldMarkInterviewReached = !hasExplicitInterviewMarker && @@ -1419,13 +1439,14 @@ export function useJobOs(userId: string | null): UseJobOsReturn { application.id === id ? { ...application, ...normalizedUpdates, updatedAt: now } : application ), roles: - !linkedRoleId || syncedRoleStatus == null + !linkedRoleId || nextRoleStatus == null ? prev.roles : prev.roles.map((role) => role.id === linkedRoleId ? { ...role, - status: syncedRoleStatus, + status: nextRoleStatus, + hasApplication: updates.archived === true ? false : true, updatedAt: now, } : role @@ -1444,14 +1465,15 @@ export function useJobOs(userId: string | null): UseJobOsReturn { { merge: true } ); - if (!linkedRoleId || syncedRoleStatus == null) { + if (!linkedRoleId || nextRoleStatus == null) { return; } await setDoc( doc(firebase.db, "users", userId, "roles", linkedRoleId), { - status: syncedRoleStatus, + status: nextRoleStatus, + hasApplication: updates.archived === true ? false : true, updatedAt: serverTimestamp(), }, { merge: true } diff --git a/src/app/pages/dashboard/DashboardPage.tsx b/src/app/pages/dashboard/DashboardPage.tsx index 28cd595..5296e23 100644 --- a/src/app/pages/dashboard/DashboardPage.tsx +++ b/src/app/pages/dashboard/DashboardPage.tsx @@ -26,6 +26,7 @@ import { AppPageShell } from "../../components/layout/AppPageShell"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../components/ui/collapsible"; import { WeeklyExecutionPanel } from "../../components/WeeklyExecutionPanel"; import { appPath } from "../../routing"; +import { isArchived } from "../../services/jobOsArchive"; export function DashboardPage() { const { session } = useApp(); @@ -68,6 +69,33 @@ export function DashboardPage() { [companies, roles, applications, loading] ); + const activeCompanies = useMemo( + () => companies.filter((company) => !isArchived(company)), + [companies] + ); + const activeCompanyIds = useMemo( + () => new Set(activeCompanies.map((company) => company.id)), + [activeCompanies] + ); + const activeRoles = useMemo( + () => roles.filter((role) => !isArchived(role) && activeCompanyIds.has(role.companyId)), + [activeCompanyIds, roles] + ); + const activeRoleIds = useMemo( + () => new Set(activeRoles.map((role) => role.id)), + [activeRoles] + ); + const activeApplications = useMemo( + () => + applications.filter( + (application) => + !isArchived(application) && + activeCompanyIds.has(application.companyId) && + activeRoleIds.has(application.roleId) + ), + [activeCompanyIds, activeRoleIds, applications] + ); + const hotOpportunities = useMemo( () => (loading ? [] : getHotOpportunities(roles, companies, applications)), [companies, roles, applications, loading] @@ -93,7 +121,7 @@ export function DashboardPage() { [companies, roles, applications, loading] ); - const isEmpty = !loading && companies.length === 0 && roles.length === 0; + const isEmpty = !loading && activeCompanies.length === 0 && activeRoles.length === 0; const showFirstRun = isEmpty; const holdDashboard = loading; const primaryAction = actions[0]; @@ -151,7 +179,7 @@ export function DashboardPage() { {showFirstRun ? (
- + diff --git a/src/app/pages/job-os/JobOsApplicationsPage.tsx b/src/app/pages/job-os/JobOsApplicationsPage.tsx index a35ca84..2454100 100644 --- a/src/app/pages/job-os/JobOsApplicationsPage.tsx +++ b/src/app/pages/job-os/JobOsApplicationsPage.tsx @@ -13,7 +13,7 @@ import { } from "../../components/ui/alert-dialog"; import { usePagination } from "../../hooks/usePagination"; import { PaginationControls } from "../../components/ui/PaginationControls"; -import { ArrowDown, ArrowUp, ArrowUpDown, CircleHelp, KanbanSquare, List, MessageSquare, Plus, Search } from "lucide-react"; +import { Archive, ArchiveRestore, ArrowDown, ArrowUp, ArrowUpDown, CircleHelp, Download, FileText, KanbanSquare, List, MessageSquare, Plus, Search } from "lucide-react"; import { Button } from "../../components/ui/button"; import { Card, CardContent } from "../../components/ui/card"; import { Checkbox } from "../../components/ui/checkbox"; @@ -38,6 +38,9 @@ import { APPLICATION_STATUS_LABELS, INTERVIEW_RELATED_APPLICATION_STATUSES, applicationReachedInterview, + buildArchiveApplicationUpdates, + buildRestoreApplicationUpdates, + isApplicationArchived, sortJobOsApplications, type ApplicationSortField, type SortDirection, @@ -45,6 +48,7 @@ import { import { getApplicationCvLabel, getDefaultCvAsset } from "../../services/cvAssets"; import { ApplicationQualityBadge } from "../../components/job-os/ApplicationQualityBadge"; import { appPath } from "../../routing"; +import { isArchived } from "../../services/jobOsArchive"; import type { ApplicationStatus, JobOsApplication, JobOsCompany, JobOsRole } from "../../types/jobOs"; const STATUS_VALUES: ApplicationStatus[] = [ @@ -63,6 +67,18 @@ type ApplicationStageGroup = keyof typeof APPLICATION_STAGE_GROUPS; type SortField = ApplicationSortField; type ClosedInterviewFilter = "all" | "withInterview"; +interface ArchivedApplicationExportRow { + no: number; + company: string; + role: string; + status: string; + dateApplied: string; + channel: string; + archivedAt: string; + archivedReason: string; + notes: string; +} + const EMPTY_APPLICATION_DRAFT = { dateApplied: new Date().toISOString().slice(0, 10), companyId: "", @@ -80,6 +96,9 @@ const EMPTY_APPLICATION_DRAFT = { tailoredCvSummary: "", tailoredCvText: "", tailoredCvUpdatedAt: undefined, + archived: false, + archivedAt: undefined, + archivedReason: undefined, }; function createDraft(defaultCv?: { id: string; name: string }): Omit { @@ -90,6 +109,31 @@ function createDraft(defaultCv?: { id: string; name: string }): Omit/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function downloadTextFile(fileName: string, content: string, type: string): void { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} + export default function JobOsApplicationsPage() { const { applications, @@ -101,7 +145,6 @@ export default function JobOsApplicationsPage() { addApplication, updateApplication, updateOutreach, - removeApplication, syncNotice, exportState, replaceState, @@ -140,7 +183,7 @@ export default function JobOsApplicationsPage() { const [selectedIds, setSelectedIds] = useState([]); const [createOpen, setCreateOpen] = useState(false); const [createDraftSnapshot, setCreateDraftSnapshot] = useState(""); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [archiveAllConfirmOpen, setArchiveAllConfirmOpen] = useState(false); const [detailId, setDetailId] = useState(null); const [detailDraft, setDetailDraft] = useState | null>(null); const [detailDraftSnapshot, setDetailDraftSnapshot] = useState(""); @@ -150,14 +193,51 @@ export default function JobOsApplicationsPage() { const companiesById = useMemo(() => new Map(companies.map((company) => [company.id, company])), [companies]); const rolesById = useMemo(() => new Map(roles.map((role) => [role.id, role])), [roles]); + const activeCompanies = useMemo(() => companies.filter((company) => !isArchived(company)), [companies]); + const activeCompanyIds = useMemo(() => new Set(activeCompanies.map((company) => company.id)), [activeCompanies]); + const activeRoles = useMemo( + () => roles.filter((role) => !isArchived(role) && activeCompanyIds.has(role.companyId)), + [activeCompanyIds, roles] + ); const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); const detailApplication = detailId ? applications.find((application) => application.id === detailId) ?? null : null; + const unarchivedApplications = useMemo( + () => applications.filter((application) => !isApplicationArchived(application)), + [applications] + ); + const archivedApplications = useMemo( + () => applications.filter(isApplicationArchived), + [applications] + ); + const archivedExportRows = useMemo( + () => + sortJobOsApplications(archivedApplications, companiesById, rolesById, "date", "desc").map( + (application, index) => ({ + no: index + 1, + company: companiesById.get(application.companyId)?.name ?? "Unknown company", + role: rolesById.get(application.roleId)?.title ?? "Unknown role", + status: APPLICATION_STATUS_LABELS[application.status], + dateApplied: application.dateApplied, + channel: application.channel, + archivedAt: application.archivedAt ?? "", + archivedReason: application.archivedReason ?? "", + notes: application.notes, + }) + ), + [archivedApplications, companiesById, rolesById] + ); const groupStatuses = APPLICATION_STAGE_GROUPS[stageGroup].statuses; const filteredApplications = useMemo(() => { const query = searchTerm.trim().toLowerCase(); const visibleApplications = applications.filter((application) => { + const isArchived = isApplicationArchived(application); + if (stageGroup === "archived") { + if (!isArchived) return false; + } else if (isArchived) { + return false; + } if (!groupStatuses.includes(application.status)) return false; if ( stageGroup === "closed" && @@ -289,6 +369,137 @@ export default function JobOsApplicationsPage() { setSelectedIds([]); } + async function archiveApplications(ids: string[], reason = "Historical cleanup"): Promise { + await Promise.all( + ids.map((id) => updateApplication(id, buildArchiveApplicationUpdates(reason))) + ); + setSelectedIds([]); + } + + async function restoreApplications(ids: string[]): Promise { + await Promise.all( + ids.map((id) => updateApplication(id, buildRestoreApplicationUpdates())) + ); + setSelectedIds([]); + } + + async function handleArchiveAllApplications(): Promise { + await archiveApplications( + unarchivedApplications.map((application) => application.id), + "Historical cleanup before new targeted application list" + ); + setArchiveAllConfirmOpen(false); + toast.success(`${unarchivedApplications.length} application${unarchivedApplications.length === 1 ? "" : "s"} archived`); + } + + function handleExportArchivedCsv(): void { + if (archivedExportRows.length === 0) return; + + const headers = [ + "No.", + "Company", + "Role", + "Status", + "Date applied", + "Channel", + "Archived at", + "Archive reason", + "Notes", + ]; + const rows = archivedExportRows.map((row) => [ + String(row.no), + row.company, + row.role, + row.status, + row.dateApplied, + row.channel, + row.archivedAt, + row.archivedReason, + row.notes, + ]); + const csv = [headers, ...rows] + .map((row) => row.map((value) => escapeCsvValue(value)).join(",")) + .join("\n"); + const date = new Date().toISOString().slice(0, 10); + + downloadTextFile( + `jobsprint-archived-applications-${date}.csv`, + `\uFEFF${csv}`, + "text/csv;charset=utf-8" + ); + toast.success("Archived applications CSV exported"); + } + + function handleExportArchivedPdf(): void { + if (archivedExportRows.length === 0) return; + + const reportWindow = window.open("", "_blank"); + if (!reportWindow) { + toast.error("Pop-up blocked. Please allow pop-ups to export the PDF."); + return; + } + + const exportedAt = new Date().toLocaleString(); + const rowsHtml = archivedExportRows + .map( + (row) => ` + + ${row.no} + ${escapeHtml(row.company)} + ${escapeHtml(row.role)} + ${escapeHtml(row.status)} + ${escapeHtml(row.dateApplied)} + ${escapeHtml(row.channel)} + ${escapeHtml(row.archivedAt ? new Date(row.archivedAt).toLocaleDateString() : "")} + ${escapeHtml(row.archivedReason)} + ${escapeHtml(row.notes)} + ` + ) + .join(""); + + reportWindow.document.write(` + + + + JobSprint Archived Applications + + + +

JobSprint Archived Applications

+
${archivedExportRows.length} archived application${archivedExportRows.length === 1 ? "" : "s"} exported ${escapeHtml(exportedAt)}
+ + + + + + + + + + + + + + + ${rowsHtml} +
No.CompanyRoleStatusDate appliedChannelArchived atArchive reasonNotes
+ + + + `); + reportWindow.document.close(); + toast.success("Archived applications PDF opened"); + } + async function bulkFollowUp(): Promise { await Promise.all(selectedIds.map((id) => updateApplication(id, { nextAction: "Follow up" }))); setSelectedIds([]); @@ -317,14 +528,15 @@ export default function JobOsApplicationsPage() { const closedInterviewCount = useMemo( () => - applications.filter( + unarchivedApplications.filter( (application) => APPLICATION_STAGE_GROUPS.closed.statuses.includes(application.status) && applicationReachedInterview(application) ).length, - [applications] + [unarchivedApplications] ); const isClosedStageGroup = stageGroup === "closed"; + const isArchivedStageGroup = stageGroup === "archived"; function handleCreateDialogKeyDown( event: React.KeyboardEvent @@ -380,6 +592,16 @@ export default function JobOsApplicationsPage() { Add Application + ); @@ -406,9 +628,11 @@ export default function JobOsApplicationsPage() { className="ml-1 rounded-full bg-background/70 px-1.5 py-0 text-[10px]" > { - applications.filter((application) => - APPLICATION_STAGE_GROUPS[group].statuses.includes(application.status) - ).length + group === "archived" + ? archivedApplications.length + : unarchivedApplications.filter((application) => + APPLICATION_STAGE_GROUPS[group].statuses.includes(application.status) + ).length } @@ -444,12 +668,39 @@ export default function JobOsApplicationsPage() {
) : null} + {stageGroup === "archived" ? ( +
+ + +
+ ) : null} +
setSearchTerm(event.target.value)} - placeholder={isClosedStageGroup ? "Search company, role, notes" : "Search company, role, next action, notes"} + placeholder={isClosedStageGroup || isArchivedStageGroup ? "Search company, role, notes" : "Search company, role, next action, notes"} className="pl-9" />
@@ -483,12 +734,25 @@ export default function JobOsApplicationsPage() { Set Follow-up ) : null} - - + {isArchivedStageGroup ? ( + + ) : ( + <> + + + + + )} @@ -516,21 +780,23 @@ export default function JobOsApplicationsPage() { + No. {renderSortHeader("Company", "company")} {renderSortHeader("Role", "role")} Quality {renderSortHeader("Status", "status")} {renderSortHeader("Date", "date")} - {!isClosedStageGroup ? ( + {!isClosedStageGroup && !isArchivedStageGroup ? ( {renderSortHeader("Next Action", "nextAction")} ) : null} - {pagedApplications.map((application) => { + {pagedApplications.map((application, index) => { const reachedInterview = applicationReachedInterview(application); const isClosedApplication = APPLICATION_STAGE_GROUPS.closed.statuses.includes(application.status); + const rowNumber = (appPage - 1) * PAGE_SIZE + index + 1; return ( toggleSelection(application.id)} /> + + {rowNumber} + {companiesById.get(application.companyId)?.name ?? "-"} {rolesById.get(application.roleId)?.title ?? "-"} - {isClosedApplication ? ( + {isApplicationArchived(application) ? ( + + + Archived + + ) : isClosedApplication ? ( reachedInterview ? ( @@ -591,7 +865,7 @@ export default function JobOsApplicationsPage() { {application.dateApplied} - {!isClosedStageGroup ? ( + {!isClosedStageGroup && !isArchivedStageGroup ? ( {application.nextAction || "-"} @@ -614,7 +888,7 @@ export default function JobOsApplicationsPage() { {filteredApplications.length === 0 ? (
@@ -678,7 +952,7 @@ export default function JobOsApplicationsPage() { - {companies.map((company) => ( + {activeCompanies.map((company) => ( {company.name} @@ -690,7 +964,7 @@ export default function JobOsApplicationsPage() { - {roles + {activeRoles .filter((role) => !draft.companyId || role.companyId === draft.companyId) .map((role) => ( @@ -788,12 +1062,30 @@ export default function JobOsApplicationsPage() { + + + + Archive all existing applications? + + This preserves every record and moves {unarchivedApplications.length} application{unarchivedApplications.length === 1 ? "" : "s"} out of the active pipeline. Archived applications remain available in the Archived filter. + + + + Cancel + void handleArchiveAllApplications()}> + Archive All + + + + + { if (!isOpen) handleCloseDetails(); }}> {detailApplication && detailDraft ? ( <> {(() => { const isClosedDetail = APPLICATION_STAGE_GROUPS.closed.statuses.includes(detailDraft.status); + const isArchivedDetail = isApplicationArchived(detailApplication); return ( <> @@ -803,6 +1095,9 @@ export default function JobOsApplicationsPage() { {rolesById.get(detailApplication.roleId)?.title ?? "Unknown role"} + {isArchivedDetail && detailApplication.archivedAt + ? ` - Archived ${new Date(detailApplication.archivedAt).toLocaleDateString()}` + : ""} @@ -875,7 +1170,7 @@ export default function JobOsApplicationsPage() { /> Reached interview stage - {!isClosedDetail ? ( + {!isClosedDetail && !isArchivedDetail ? (
Tailor CV - + {isApplicationArchived(detailApplication) ? ( - - - Delete this application? - - This record will be permanently removed. - - - - Cancel - { - void removeApplication(detailApplication.id).then(() => - toast.success("Application deleted") - ); - closeDetails(); - }} - > - Delete - - - - + ) : ( + + )}
Cmd/Ctrl + Enter saves diff --git a/src/app/pages/job-os/JobOsCompaniesPage.tsx b/src/app/pages/job-os/JobOsCompaniesPage.tsx index ebbd7d9..8a2c315 100644 --- a/src/app/pages/job-os/JobOsCompaniesPage.tsx +++ b/src/app/pages/job-os/JobOsCompaniesPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { AlertDialog, @@ -15,6 +15,8 @@ import { ArrowDown, ArrowUp, ArrowUpDown, + Archive, + ArchiveRestore, ChevronDown, ChevronRight, Download, @@ -22,7 +24,6 @@ import { Link, Pencil, Plus, - Trash2, Upload, X, } from "lucide-react"; @@ -66,6 +67,7 @@ import { JobOsLayout } from "../../components/job-os/JobOsLayout"; import { JobOsTransferControls } from "../../components/job-os/JobOsTransferControls"; import { PasteLinkImportDialog } from "../../components/job-os/PasteLinkImportDialog"; import { getDefaultCvAsset } from "../../services/cvAssets"; +import { buildArchiveUpdates, buildRestoreUpdates, isArchived, STRATEGIC_RESET_ARCHIVE_REASON } from "../../services/jobOsArchive"; import type { CompanyPriority, CompanyStatus, JobOsCompany } from "../../types/jobOs"; const STATUS_VALUES: CompanyStatus[] = [ @@ -76,6 +78,7 @@ const STATUS_VALUES: CompanyStatus[] = [ "Interviewing", "Closed", ]; +const ROMAN_TARGET_COMPANIES_IMPORT_URL = "/templates/roman-target-companies-jobsprint-import.csv"; function parseCsvLine(line: string): string[] { const values: string[] = []; @@ -105,6 +108,28 @@ function parseCsvLine(line: string): string[] { return values; } +function getTierFromNotes(notes?: string): string | null { + const tier = notes?.match(/\bTier:\s*([^|]+)/i)?.[1]?.trim(); + return tier || null; +} + +function getTierExplanation(tier: string | null): string { + const normalizedTier = tier?.toUpperCase(); + if (normalizedTier === "S") { + return "Tier S: strongest strategic fit. Prioritize first for applications, tailoring, and networking."; + } + if (normalizedTier === "A") { + return "Tier A: strong fit. Keep in the active weekly target set after Tier S companies."; + } + if (normalizedTier === "B") { + return "Tier B: secondary target. Pursue when a matching role appears or capacity allows."; + } + if (normalizedTier === "C") { + return "Tier C: exploratory or lower-fit target. Keep for research, not daily execution."; + } + return "No tier marker found. This note is general research context for the company."; +} + function normalizePriority(value: string): CompanyPriority | null { const cleaned = value.trim().toUpperCase(); if (cleaned === "A" || cleaned === "B" || cleaned === "C") { @@ -151,9 +176,11 @@ export default function JobOsCompaniesPage() { addCompany, updateCompany, addRole, + updateRole, addOutreach, + updateOutreach, addApplication, - removeCompany, + updateApplication, syncNotice, exportState, replaceState, @@ -161,6 +188,7 @@ export default function JobOsCompaniesPage() { const { session } = useApp(); const fileInputRef = useRef(null); + const targetListAutoImportRef = useRef(false); const [addFormOpen, setAddFormOpen] = useState(false); const [search, setSearch] = useState(""); const [isImporting, setIsImporting] = useState(false); @@ -170,6 +198,7 @@ export default function JobOsCompaniesPage() { const [companyListLocked, setCompanyListLocked] = useState(false); const [sortKey, setSortKey] = useState("name"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const [showArchived, setShowArchived] = useState(false); const [selectedCompanyId, setSelectedCompanyId] = useState(null); const [detailMode, setDetailMode] = useState<"view" | "edit">("view"); const [editDraft, setEditDraft] = useState>({}); @@ -193,10 +222,19 @@ export default function JobOsCompaniesPage() { const filtered = useMemo(() => { const q = search.toLowerCase(); return companies.filter((c) => - [c.name, c.industry, c.notes].join(" ").toLowerCase().includes(q) + (showArchived ? isArchived(c) : !isArchived(c)) && + [c.name, c.industry, c.notes, c.archivedReason ?? ""].join(" ").toLowerCase().includes(q) ); - }, [companies, search]); + }, [companies, search, showArchived]); const defaultCv = getDefaultCvAsset(assets.cvs); + const activeCompanies = useMemo( + () => companies.filter((company) => !isArchived(company)), + [companies] + ); + const archivedCompanies = useMemo( + () => companies.filter(isArchived), + [companies] + ); const sortedCompanies = useMemo(() => { const priorityRank: Record = { A: 0, B: 1, C: 2 }; @@ -242,7 +280,7 @@ export default function JobOsCompaniesPage() { const industryStorageKey = `job_os_industries_${session?.userId ?? "anon"}`; const industryOptions = useMemo(() => { const byKey = new Map(); - [...companies.map((company) => company.industry), ...customIndustries].forEach((industry) => { + [...activeCompanies.map((company) => company.industry), ...customIndustries].forEach((industry) => { const cleaned = industry.trim().replace(/\s+/g, " "); if (!cleaned) return; byKey.set(normalizeIndustryKey(cleaned), cleaned); @@ -250,7 +288,7 @@ export default function JobOsCompaniesPage() { return Array.from(byKey.values()).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }) ); - }, [companies, customIndustries]); + }, [activeCompanies, customIndustries]); useEffect(() => { if (!session?.userId) return; @@ -285,7 +323,7 @@ export default function JobOsCompaniesPage() { useEffect(() => { resetCompaniesPage(); - }, [resetCompaniesPage, search, sortDir, sortKey]); + }, [resetCompaniesPage, search, showArchived, sortDir, sortKey]); function toggleSort(nextKey: CompanySortKey): void { if (sortKey === nextKey) { @@ -312,7 +350,7 @@ export default function JobOsCompaniesPage() { ); } - async function importCompanyText(text: string, sourceLabel: "CSV" | "paste"): Promise { + const importCompanyText = useCallback(async (text: string, sourceLabel: "CSV" | "paste"): Promise => { const lines = text .split(/\r?\n/) .map((line) => line.trim()) @@ -356,7 +394,7 @@ export default function JobOsCompaniesPage() { let updated = 0; const errors: string[] = []; const existingByName = new Map( - companies.map((company) => [normalizeCompanyKey(company.name), company.id]) + activeCompanies.map((company) => [normalizeCompanyKey(company.name), company.id]) ); const seenInBatch = new Set(); const pick = (row: string[], key: string): string => { @@ -435,7 +473,7 @@ export default function JobOsCompaniesPage() { prev ? `${prev} Company list is now locked.` : "Company list is now locked." ); } - } + }, [activeCompanies, addCompany, lockAfterImport, updateCompany]); async function handleImportCsv(file: File): Promise { setIsImporting(true); @@ -513,7 +551,7 @@ export default function JobOsCompaniesPage() { setEditDraft((current) => ({ ...current, industry: nextIndustry })); } - const companiesToUpdate = companies.filter( + const companiesToUpdate = activeCompanies.filter( (company) => normalizeIndustryKey(company.industry) === originalKey ); await Promise.all( @@ -537,14 +575,74 @@ export default function JobOsCompaniesPage() { } } - const [clearAllOpen, setClearAllOpen] = useState(false); + const [archiveAllOpen, setArchiveAllOpen] = useState(false); + + async function archiveCompanies(companyIds: string[], reason = STRATEGIC_RESET_ARCHIVE_REASON): Promise { + const archiveUpdates = buildArchiveUpdates(reason); + const companyIdSet = new Set(companyIds); + const linkedRoles = roles.filter((role) => companyIdSet.has(role.companyId) && !isArchived(role)); + const linkedApplications = applications.filter((application) => companyIdSet.has(application.companyId) && !isArchived(application)); + const linkedOutreach = outreach.filter((item) => companyIdSet.has(item.companyId) && !isArchived(item)); + + await Promise.all([ + ...companyIds.map((id) => updateCompany(id, { ...archiveUpdates, status: "Closed" })), + ...linkedRoles.map((role) => updateRole(role.id, { ...archiveUpdates, status: "closed" })), + ...linkedApplications.map((application) => + updateApplication(application.id, { ...archiveUpdates, nextAction: "" }) + ), + ...linkedOutreach.map((item) => updateOutreach(item.id, { ...archiveUpdates, status: "closed" })), + ]); + } - async function clearAllCompanies(): Promise { - const count = companies.length; - await Promise.all(companies.map((company) => removeCompany(company.id))); + const handleLoadRomanTargetCompanies = useCallback(async (): Promise => { + setIsImporting(true); + setImportNotice(null); + try { + const response = await fetch(ROMAN_TARGET_COMPANIES_IMPORT_URL); + if (!response.ok) { + throw new Error(`Target company import failed (${response.status})`); + } + await importCompanyText(await response.text(), "CSV"); + } catch (error) { + const message = error instanceof Error ? error.message : "Target companies could not be loaded."; + setImportNotice(message); + toast.error(message); + } finally { + setIsImporting(false); + } + }, [importCompanyText]); + + useEffect(() => { + if (targetListAutoImportRef.current || isImporting || activeCompanies.length > 0) { + return; + } + + targetListAutoImportRef.current = true; + void handleLoadRomanTargetCompanies(); + }, [activeCompanies.length, handleLoadRomanTargetCompanies, isImporting]); + + async function restoreCompanies(companyIds: string[]): Promise { + const restoreUpdates = buildRestoreUpdates(); + const companyIdSet = new Set(companyIds); + const linkedRoles = roles.filter((role) => companyIdSet.has(role.companyId) && isArchived(role)); + const linkedApplications = applications.filter((application) => companyIdSet.has(application.companyId) && isArchived(application)); + const linkedOutreach = outreach.filter((item) => companyIdSet.has(item.companyId) && isArchived(item)); + + await Promise.all([ + ...companyIds.map((id) => updateCompany(id, restoreUpdates)), + ...linkedRoles.map((role) => updateRole(role.id, restoreUpdates)), + ...linkedApplications.map((application) => updateApplication(application.id, restoreUpdates)), + ...linkedOutreach.map((item) => updateOutreach(item.id, restoreUpdates)), + ]); + } + + async function archiveAllCompanies(): Promise { + const count = activeCompanies.length; + await archiveCompanies(activeCompanies.map((company) => company.id), "Strategic reset: archived historical company workspace"); setSelectedCompanyId(null); - setImportNotice(`Deleted ${count} companies.`); - toast.success(`Deleted ${count} companies`); + setArchiveAllOpen(false); + setImportNotice(`Archived ${count} companies and linked records.`); + toast.success(`Archived ${count} companies`); } return ( @@ -600,6 +698,16 @@ export default function JobOsCompaniesPage() { {isImporting ? "Importing…" : "Import CSV"} + - + - Delete all {companies.length} companies? + Archive active workspace? - Every company and all linked roles, applications, and outreach records will - be permanently removed. This cannot be undone. + This preserves {activeCompanies.length} active compan{activeCompanies.length === 1 ? "y" : "ies"} and all linked roles, applications, and outreach records, then moves them out of default workspace views. Cancel void clearAllCompanies()} + onClick={() => void archiveAllCompanies()} > - Delete all + Archive workspace @@ -821,17 +928,33 @@ export default function JobOsCompaniesPage() {
- Target Companies{" "} - {companies.length > 0 && ( - ({companies.length}) + {showArchived ? "Archived Companies" : "Target Companies"}{" "} + {sortedCompanies.length > 0 && ( + ({sortedCompanies.length}) )} - setSearch(e.target.value)} - placeholder="Filter company…" - /> +
+ + setSearch(e.target.value)} + placeholder="Filter company..." + /> +
@@ -876,8 +999,22 @@ export default function JobOsCompaniesPage() { {company.remotePolicy} {company.priority} {company.status} - - {company.notes || "-"} + + + + + {company.notes || "-"} + + + +
+

{getTierExplanation(getTierFromNotes(company.notes))}

+ {company.notes && ( +

{company.notes}

+ )} +
+
+
@@ -915,53 +1052,47 @@ export default function JobOsCompaniesPage() { Edit - - - - - - - - - Delete {company.name}? - -
- This will permanently remove the company record. - {(() => { - const linkedRoles = roles.filter((r) => r.companyId === company.id).length; - const linkedApps = applications.filter((a) => a.companyId === company.id).length; - if (linkedRoles === 0 && linkedApps === 0) return null; - return ( - - Warning: {linkedRoles > 0 && `${linkedRoles} role${linkedRoles !== 1 ? "s" : ""}`} - {linkedRoles > 0 && linkedApps > 0 && " and "} - {linkedApps > 0 && `${linkedApps} application${linkedApps !== 1 ? "s" : ""}`} - {" "}linked to this company will also be deleted. - - ); - })()} -
-
-
- - Cancel - void removeCompany(company.id).then(() => toast.success("Company deleted"))} - > - Delete - - -
-
+ {isArchived(company) ? ( + + + + + + + Restore linked records + + ) : ( + + + + + + + Archive linked records + + )}
@@ -1070,19 +1201,19 @@ export default function JobOsCompaniesPage() {
- {roles.filter((r) => r.companyId === selectedCompany.id).length} + {roles.filter((r) => r.companyId === selectedCompany.id && !isArchived(r)).length}
Roles
- {outreach.filter((o) => o.companyId === selectedCompany.id).length} + {outreach.filter((o) => o.companyId === selectedCompany.id && !isArchived(o)).length}
Outreach
- {applications.filter((a) => a.companyId === selectedCompany.id).length} + {applications.filter((a) => a.companyId === selectedCompany.id && !isArchived(a)).length}
Applications
@@ -1341,7 +1472,7 @@ export default function JobOsCompaniesPage() {
{industryOptions.length > 0 ? ( industryOptions.map((industry) => { - const usageCount = companies.filter( + const usageCount = activeCompanies.filter( (company) => normalizeIndustryKey(company.industry) === normalizeIndustryKey(industry) ).length; const isEditing = editingIndustry === industry; @@ -1428,7 +1559,7 @@ export default function JobOsCompaniesPage() { setPasteLinkOpen(false)} - existingCompanies={companies} + existingCompanies={activeCompanies} addCompany={addCompany} updateCompany={updateCompany} addRole={addRole} diff --git a/src/app/pages/job-os/JobOsOutreachPage.tsx b/src/app/pages/job-os/JobOsOutreachPage.tsx index a1f59cc..29c4d4c 100644 --- a/src/app/pages/job-os/JobOsOutreachPage.tsx +++ b/src/app/pages/job-os/JobOsOutreachPage.tsx @@ -1,16 +1,6 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "../../components/ui/alert-dialog"; +import { Archive, ArchiveRestore } from "lucide-react"; import { Button } from "../../components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; @@ -20,12 +10,31 @@ import { Textarea } from "../../components/ui/textarea"; import { useJobOsContext } from "../../context/JobOsContext"; import { JobOsTransferControls } from "../../components/job-os/JobOsTransferControls"; import { JobOsLayout } from "../../components/job-os/JobOsLayout"; +import { buildArchiveUpdates, buildRestoreUpdates, isArchived } from "../../services/jobOsArchive"; import type { ApplicationStatus, JobOsOutreach, OutreachStatus } from "../../types/jobOs"; const OUTREACH_STATUSES: OutreachStatus[] = ["sent", "replied", "meeting", "no_reply", "closed"]; export default function JobOsOutreachPage() { - const { outreach, applications, companies, roles, assets, addOutreach, updateOutreach, updateApplication, removeOutreach, syncNotice, exportState, replaceState } = useJobOsContext(); + const { outreach, applications, companies, roles, assets, addOutreach, updateOutreach, updateApplication, syncNotice, exportState, replaceState } = useJobOsContext(); + const [showArchived, setShowArchived] = useState(false); + + const activeCompanies = useMemo(() => companies.filter((company) => !isArchived(company)), [companies]); + const activeCompanyIds = useMemo(() => new Set(activeCompanies.map((company) => company.id)), [activeCompanies]); + const activeRoles = useMemo( + () => roles.filter((role) => !isArchived(role) && activeCompanyIds.has(role.companyId)), + [activeCompanyIds, roles] + ); + const visibleOutreach = useMemo( + () => + outreach.filter((item) => + showArchived + ? isArchived(item) + : !isArchived(item) && activeCompanyIds.has(item.companyId) && (!item.roleId || activeRoles.some((role) => role.id === item.roleId)) + ), + [activeCompanyIds, activeRoles, outreach, showArchived] + ); + const archivedOutreachCount = useMemo(() => outreach.filter(isArchived).length, [outreach]); async function handleOutreachStatusChange(item: JobOsOutreach, newStatus: OutreachStatus): Promise { await updateOutreach(item.id, { status: newStatus }); @@ -33,7 +42,7 @@ export default function JobOsOutreachPage() { if ((newStatus === "replied" || newStatus === "meeting") && item.roleId) { const targetAppStatus: ApplicationStatus = newStatus === "meeting" ? "interview" : "screen"; const linkedApp = applications.find( - (a) => a.roleId === item.roleId && a.status !== "rejected" && a.status !== "ghosted" && a.status !== "offer" + (a) => !isArchived(a) && a.roleId === item.roleId && a.status !== "rejected" && a.status !== "ghosted" && a.status !== "offer" ); if (linkedApp) { @@ -77,13 +86,13 @@ export default function JobOsOutreachPage() { setDraft((p) => ({ ...p, contactName: e.target.value }))} placeholder="Contact name" /> @@ -128,7 +137,21 @@ export default function JobOsOutreachPage() { - Outreach Log + +
+ Outreach Log + +
+
@@ -146,7 +169,7 @@ export default function JobOsOutreachPage() { - {outreach.map((item) => ( + {visibleOutreach.map((item) => ( {companies.find((c) => c.id === item.companyId)?.name ?? "-"} {roles.find((r) => r.id === item.roleId)?.title ?? "-"} @@ -180,28 +203,22 @@ export default function JobOsOutreachPage() { > Schedule follow-up - - - - - - - Delete outreach record? - - This record will be permanently removed. - - - - Cancel - void removeOutreach(item.id).then(() => toast.success("Outreach record deleted"))} - > - Delete - - - - + ))} diff --git a/src/app/pages/job-os/JobOsRolesPage.tsx b/src/app/pages/job-os/JobOsRolesPage.tsx index 662bfff..8a1e383 100644 --- a/src/app/pages/job-os/JobOsRolesPage.tsx +++ b/src/app/pages/job-os/JobOsRolesPage.tsx @@ -1,28 +1,19 @@ import { Link } from "react-router"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "../../components/ui/alert-dialog"; import { ArrowDown, ArrowUp, ArrowUpDown, + Archive, + ArchiveRestore, BriefcaseBusiness, + Check, CheckCircle2, Pencil, Rocket, Search, SlidersHorizontal, - Trash2, WandSparkles, X, } from "lucide-react"; @@ -41,6 +32,7 @@ import { JobOsLayout } from "../../components/job-os/JobOsLayout"; import { JobOsTransferControls } from "../../components/job-os/JobOsTransferControls"; import { appPath } from "../../routing"; import { getRecommendedCvForTrack } from "../../services/cvAssets"; +import { buildArchiveUpdates, buildRestoreUpdates, isArchived } from "../../services/jobOsArchive"; import type { JobOsRole, JobTrack, RoleStatus } from "../../types/jobOs"; type RoleOrigin = "self_sourced" | "recruiter"; @@ -87,14 +79,13 @@ export default function JobOsRolesPage() { cvProfiles, updateRole, addApplication, - removeRole, syncNotice, exportState, replaceState, } = useJobOsContext(); const [editingRoleId, setEditingRoleId] = useState(null); - const [editDraft, setEditDraft] = useState | null>(null); + const [editDraft, setEditDraft] = useState | null>(null); const [actionNotice, setActionNotice] = useState<{ tone: "success" | "error"; message: string } | null>(null); const [sortKey, setSortKey] = useState("createdAt"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); @@ -109,12 +100,21 @@ export default function JobOsRolesPage() { }); const [unifiedSearch, setUnifiedSearch] = useState(""); const [showMoreFilters, setShowMoreFilters] = useState(false); + const [showArchived, setShowArchived] = useState(false); const [selectedJdRoleId, setSelectedJdRoleId] = useState(null); const companiesById = useMemo( () => new Map(companies.map((company) => [company.id, company])), [companies] ); + const activeCompanyIds = useMemo( + () => new Set(companies.filter((company) => !isArchived(company)).map((company) => company.id)), + [companies] + ); + const archivedRolesCount = useMemo( + () => roles.filter(isArchived).length, + [roles] + ); const filtered = useMemo(() => { const searchQuery = unifiedSearch.trim().toLowerCase(); @@ -122,6 +122,11 @@ export default function JobOsRolesPage() { return roles.filter((role) => { const companyName = companiesById.get(role.companyId)?.name ?? ""; + if (showArchived) { + if (!isArchived(role)) return false; + } else if (isArchived(role) || !activeCompanyIds.has(role.companyId)) { + return false; + } if (searchQuery) { const haystack = `${companyName} ${role.title}`.toLowerCase(); @@ -142,7 +147,7 @@ export default function JobOsRolesPage() { return true; }); - }, [companiesById, roles, tableFilters, unifiedSearch]); + }, [activeCompanyIds, companiesById, roles, showArchived, tableFilters, unifiedSearch]); const sortedRoles = useMemo(() => { const statusRank: Record = { @@ -212,7 +217,7 @@ export default function JobOsRolesPage() { }, [resetRolesPage, sortDir, sortKey, tableFilters, unifiedSearch]); const applicationRoleIds = useMemo( - () => new Set(applications.map((application) => application.roleId).filter(Boolean)), + () => new Set(applications.filter((application) => !isArchived(application)).map((application) => application.roleId).filter(Boolean)), [applications] ); const selectedJdRole = @@ -266,9 +271,17 @@ export default function JobOsRolesPage() { origin: role.origin ?? "self_sourced", jobDescription: role.jobDescription ?? "", jobDescriptionUpdatedAt: role.jobDescriptionUpdatedAt, + createdAt: role.createdAt, }); } + function openJdEditor(role: JobOsRole): void { + if (editingRoleId !== role.id) { + startEdit(role); + } + setSelectedJdRoleId(role.id); + } + function cancelEdit(): void { setEditingRoleId(null); setEditDraft(null); @@ -325,6 +338,19 @@ export default function JobOsRolesPage() { } } + async function archiveRole(role: JobOsRole): Promise { + await updateRole(role.id, { + ...buildArchiveUpdates("Archived from Roles page"), + status: "closed", + }); + toast.success("Role archived"); + } + + async function restoreRole(role: JobOsRole): Promise { + await updateRole(role.id, buildRestoreUpdates()); + toast.success("Role restored"); + } + return ( {showMoreFilters ? "Fewer filters" : "More filters"} + {(unifiedSearch || tableFilters.status !== "all" || tableFilters.location || tableFilters.seniority !== "all" || tableFilters.track !== "all" || tableFilters.fitMin !== "all") && ( @@ -598,7 +648,7 @@ export default function JobOsRolesPage() { @@ -717,53 +767,25 @@ export default function JobOsRolesPage() { Quick apply - - - - - - - - - - Delete - - - - Delete this role? - -
- {role.title} will be permanently removed. - {(() => { - const linkedApps = applications.filter((a) => a.roleId === role.id).length; - if (linkedApps === 0) return null; - return ( - - Warning: {linkedApps} application{linkedApps !== 1 ? "s" : ""} linked to this role will also be deleted. - - ); - })()} -
-
-
- - Cancel - void removeRole(role.id).then(() => toast.success("Role deleted"))} + + + + + + + {isArchived(role) ? "Restore" : "Archive"} + @@ -787,51 +809,109 @@ export default function JobOsRolesPage() { if (!open) setSelectedJdRoleId(null); }} > - - - {selectedJdRole?.title ?? "Job Description"} + + + {selectedJdRole?.title ?? "Job Description"} {selectedJdRole ? ( -
-
-
- Company:{" "} - {companiesById.get(selectedJdRole.companyId)?.name ?? "-"} -
-
- Location:{" "} - {(selectedJdDraft?.location ?? selectedJdRole.location) || "-"} -
-
- Seniority:{" "} - {normalizeSeniority(selectedJdDraft?.seniority ?? selectedJdRole.seniority) || "-"} -
-
- Track:{" "} - {selectedJdDraft?.track ?? selectedJdRole.track} -
-
- Fit:{" "} - {selectedJdDraft?.fitScore ?? selectedJdRole.fitScore}/5 +
+
+
+
+
Company
+
+ {companiesById.get(selectedJdRole.companyId)?.name ?? "-"} +
+
+
+
Location
+
+ {(selectedJdDraft?.location ?? selectedJdRole.location) || "-"} +
+
+
+
Seniority
+
+ {normalizeSeniority(selectedJdDraft?.seniority ?? selectedJdRole.seniority) || "-"} +
+
+
+
Track
+
{selectedJdDraft?.track ?? selectedJdRole.track}
+
+
+
Fit
+
+ {selectedJdDraft?.fitScore ?? selectedJdRole.fitScore}/5 +
+
+ + {selectedJdDraft ? ( +
+
+ + + setEditDraft((current) => + current ? { ...current, url: event.target.value } : current + ) + } + placeholder="https://company.com/careers/role" + /> +
+
+ +