diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 3e9a857..1d74de6 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -79,11 +79,28 @@ namespace JobTrackerApi.Controllers private sealed record AttachmentContextResult(string Context, List Signals, List UsedFiles); - private async Task BuildAttachmentContextAsync(int jobId, CancellationToken cancellationToken) + private async Task BuildAttachmentContextAsync(int jobId, CancellationToken cancellationToken, string? attachmentIdsCsv = null) { - var attachments = await _db.Attachments + HashSet? allowedIds = null; + if (!string.IsNullOrWhiteSpace(attachmentIdsCsv)) + { + allowedIds = attachmentIdsCsv + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(value => int.TryParse(value, out var id) ? id : 0) + .Where(id => id > 0) + .ToHashSet(); + } + + var query = _db.Attachments .AsNoTracking() - .Where(a => a.JobApplicationId == jobId) + .Where(a => a.JobApplicationId == jobId); + + if (allowedIds is not null && allowedIds.Count > 0) + { + query = query.Where(a => allowedIds.Contains(a.Id)); + } + + var attachments = await query .OrderByDescending(a => a.UploadDate) .Take(4) .ToListAsync(cancellationToken); @@ -1754,7 +1771,7 @@ Candidate master CV: } [HttpPost("{id:int}/generate-application-package")] - public async Task> GenerateApplicationPackage([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? coverLetterStyle, CancellationToken cancellationToken) + public async Task> GenerateApplicationPackage([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? coverLetterStyle, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) { var job = await _db.JobApplications .Include(j => j.Company) @@ -1780,7 +1797,7 @@ Candidate master CV: var packageModeInstruction = BuildPackageModeInstruction(mode); var coverLetterStyleInstruction = BuildCoverLetterStyleInstruction(coverLetterStyle); - var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken); + var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds); var packageContext = $@"Job title: {job.JobTitle} Company: {job.Company?.Name} diff --git a/docs/jobbjakt-cleanup-tracker.md b/docs/jobbjakt-cleanup-tracker.md index ee12a6f..0d2d58c 100644 --- a/docs/jobbjakt-cleanup-tracker.md +++ b/docs/jobbjakt-cleanup-tracker.md @@ -20,6 +20,7 @@ Last updated: 2026-03-23 - [ ] Complete final UI overhaul / visual consistency pass - [x] Add frontend 404 page - [x] Add frontend route error page +- [x] Add route-level lazy loading/code splitting for heavier screens ## Job Creation / Company Features - [x] Remove “Next Action” from create job form diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 16748f7..a37a9d4 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { Suspense, lazy, useEffect, useMemo, useState } from "react"; import { Box, Button, CssBaseline, Typography } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; @@ -23,28 +23,30 @@ import { ToastProvider } from "./toast"; import { ConfirmProvider } from "./confirm"; import { PromptProvider } from "./prompt"; -import JobTable, { JobTableColumns } from "./components/JobTable"; -import AddJobModal from "./components/AddJobModal"; -import KanbanBoard from "./components/KanbanBoard"; -import DashboardView from "./components/DashboardView"; -import CompaniesTable from "./components/CompaniesTable"; -import SettingsView from "./components/SettingsView"; -import RemindersView from "./components/RemindersView"; -import QuickCommandDialog from "./components/QuickCommandDialog"; +import JobTable from "./components/JobTable"; +import type { JobTableColumns } from "./components/JobTable"; import { I18nProvider, useI18n } from "./i18n/I18nProvider"; import LoginPage from "./pages/LoginPage"; -import ProfilePage from "./pages/ProfilePage"; -import AdminAuditPage from "./pages/AdminAuditPage"; -import AdminUsersPage from "./pages/AdminUsersPage"; -import AdminSystemPage from "./pages/AdminSystemPage"; import ResetPasswordPage from "./pages/ResetPasswordPage"; -import NotFoundPage from "./pages/NotFoundPage"; import RouteErrorPage from "./pages/RouteErrorPage"; import { api } from "./api"; import { clearAuthToken, getAuthToken } from "./auth"; import AppShell, { NavItem } from "./layout/AppShell"; import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs"; +const AddJobModal = lazy(() => import("./components/AddJobModal")); +const KanbanBoard = lazy(() => import("./components/KanbanBoard")); +const DashboardView = lazy(() => import("./components/DashboardView")); +const CompaniesTable = lazy(() => import("./components/CompaniesTable")); +const SettingsView = lazy(() => import("./components/SettingsView")); +const RemindersView = lazy(() => import("./components/RemindersView")); +const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog")); +const ProfilePage = lazy(() => import("./pages/ProfilePage")); +const AdminAuditPage = lazy(() => import("./pages/AdminAuditPage")); +const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage")); +const AdminSystemPage = lazy(() => import("./pages/AdminSystemPage")); +const NotFoundPage = lazy(() => import("./pages/NotFoundPage")); + type AuthConfig = { requireAuth: boolean }; type MeResponse = { provider?: "local" | "google" | "external"; @@ -88,6 +90,10 @@ function titleFor(path: string, t: (k: any) => string): string { return t("appTitle"); } +function PageLoader() { + return Loading...; +} + function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange, accentColor, onAccentColorChange, onResetAccentColor }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; accentColor: string; onAccentColorChange: (v: string) => void; onResetAccentColor: () => void; }) { const location = useLocation(); const navigate = useNavigate(); @@ -183,25 +189,29 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo onSignOut={() => { clearAuthToken(); navigate("/login"); }} rightActions={rightActions} > - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + - setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} /> - setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} /> + + setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} /> + setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} /> + ); } diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 59aacc9..ea562cc 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -28,6 +28,14 @@ import Attachments from "./Attachments"; import JobFlowBar from "./JobFlowBar"; import { useI18n } from "../i18n/I18nProvider"; +type AttachmentItem = { + id: number; + fileName: string; + uploadDate: string; + fileType: string; + fileSize: number; +}; + type FollowUpDraft = { subject: string; body: string; @@ -93,6 +101,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false); const [readiness, setReadiness] = useState(null); const [loadingReadiness, setLoadingReadiness] = useState(false); + const [jobAttachments, setJobAttachments] = useState([]); + const [selectedAttachmentIds, setSelectedAttachmentIds] = useState([]); const [savingTailoredCv, setSavingTailoredCv] = useState(false); const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false); const [generatingPackage, setGeneratingPackage] = useState(false); @@ -115,12 +125,22 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, setInterviewPrep(null); setReadiness(null); setApplicationPackage(null); + setJobAttachments([]); + setSelectedAttachmentIds([]); api.get(`/jobapplications/${jobId}`).then((r) => { setJob(r.data); setTailoredCvText(r.data.tailoredCvText ?? ""); setDraftRecipient(r.data.company?.recruiterEmail ?? ""); setFollowUpMode(initialFollowUpMode || (r.data.status?.includes("Interview") ? "post-interview" : r.data.status === "Waiting" ? "waiting-update" : r.data.status === "Offer" ? "offer-checkin" : r.data.status === "Rejected" ? "feedback-request" : "post-apply")); }); + api.get(`/attachments/${jobId}`).then((r) => { + const items = Array.isArray(r.data) ? r.data : []; + setJobAttachments(items); + setSelectedAttachmentIds(items.slice(0, 3).map((item) => item.id)); + }).catch(() => { + setJobAttachments([]); + setSelectedAttachmentIds([]); + }); api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false)); api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([])); }, [open, jobId, initialTab, initialFollowUpMode]); @@ -303,7 +323,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, if (!jobId) return; setGeneratingPackage(true); try { - const res = await api.post(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle } }); + const res = await api.post(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle, attachmentIds: selectedAttachmentIds.join(",") || undefined } }); setApplicationPackage(res.data); setTailoredCvText(res.data.tailoredCvText ?? ""); toast(t("jobDetailsPackageGenerated"), "success"); @@ -333,6 +353,25 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, {t("jobDetailsTailoredCvIntro")} + {jobAttachments.length > 0 ? ( + + {t("jobDetailsAttachmentContextPicker")} + + {jobAttachments.map((attachment) => { + const selected = selectedAttachmentIds.includes(attachment.id); + return ( + setSelectedAttachmentIds((current) => current.includes(attachment.id) ? current.filter((id) => id !== attachment.id) : [...current, attachment.id].slice(-4))} + /> + ); + })} + + + ) : null} setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} /> {t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })} diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index a5e17f8..b44b5ea 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -693,6 +693,7 @@ export const translations = { jobDetailsTailoredCvSaved: "Tailored CV saved.", jobDetailsTailoredCvSaveFailed: "Failed to save tailored CV.", jobDetailsTailoredCvIntro: "Generate a full application package, then edit and save the tailored resume you actually want to use for this role.", + jobDetailsAttachmentContextPicker: "Use these attachments as AI context", jobDetailsTailoredCvPlaceholder: "Paste or rewrite the version of your CV you want to use for this role.", jobDetailsLastUpdated: "Last updated: {value}", jobDetailsNotSavedYet: "Not saved yet", @@ -1465,6 +1466,7 @@ export const translations = { jobDetailsTailoredCvSaved: "Tilpasset CV lagret.", jobDetailsTailoredCvSaveFailed: "Kunne ikke lagre tilpasset CV.", jobDetailsTailoredCvIntro: "Generer en full søknadspakke, og rediger og lagre deretter den tilpassede CV-en du faktisk vil bruke for denne rollen.", + jobDetailsAttachmentContextPicker: "Bruk disse vedleggene som AI-kontekst", jobDetailsTailoredCvPlaceholder: "Lim inn eller skriv om versjonen av CV-en du vil bruke for denne rollen.", jobDetailsLastUpdated: "Sist oppdatert: {value}", jobDetailsNotSavedYet: "Ikke lagret ennå",