Add attachment selection controls and lazy-load app screens

This commit is contained in:
cesnimda
2026-03-23 22:23:00 +01:00
parent 0c8258e90f
commit 73983526d3
5 changed files with 106 additions and 37 deletions
@@ -79,11 +79,28 @@ namespace JobTrackerApi.Controllers
private sealed record AttachmentContextResult(string Context, List<string> Signals, List<string> UsedFiles);
private async Task<AttachmentContextResult?> BuildAttachmentContextAsync(int jobId, CancellationToken cancellationToken)
private async Task<AttachmentContextResult?> BuildAttachmentContextAsync(int jobId, CancellationToken cancellationToken, string? attachmentIdsCsv = null)
{
var attachments = await _db.Attachments
HashSet<int>? 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<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? coverLetterStyle, CancellationToken cancellationToken)
public async Task<ActionResult<GenerateApplicationPackageDto>> 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}
+1
View File
@@ -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
+41 -31
View File
@@ -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 <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
}
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}
>
<Routes>
<Route path="/" element={<Navigate to="/jobs" replace />} />
<Route path="/dashboard" element={<DashboardView />} />
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
<Route path="/reminders" element={<RemindersView />} />
<Route path="/kanban" element={<KanbanBoard />} />
<Route path="/companies" element={<CompaniesTable />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin/audit" element={<AdminAuditPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/system" element={<AdminSystemPage />} />
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Navigate to="/jobs" replace />} />
<Route path="/dashboard" element={<DashboardView />} />
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
<Route path="/reminders" element={<RemindersView />} />
<Route path="/kanban" element={<KanbanBoard />} />
<Route path="/companies" element={<CompaniesTable />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin/audit" element={<AdminAuditPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/system" element={<AdminSystemPage />} />
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</AppShell>
<AddJobModal open={addOpen} onClose={() => setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} />
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
<Suspense fallback={null}>
<AddJobModal open={addOpen} onClose={() => setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} />
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
</Suspense>
</>
);
}
@@ -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<ReadinessResponse | null>(null);
const [loadingReadiness, setLoadingReadiness] = useState(false);
const [jobAttachments, setJobAttachments] = useState<AttachmentItem[]>([]);
const [selectedAttachmentIds, setSelectedAttachmentIds] = useState<number[]>([]);
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<JobApplication>(`/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<AttachmentItem[]>(`/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<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode, coverLetterStyle } });
const res = await api.post<ApplicationPackageResponse>(`/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,
</Box>
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
{jobAttachments.length > 0 ? (
<Box sx={{ mb: 1.5 }}>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 0.75 }}>{t("jobDetailsAttachmentContextPicker")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{jobAttachments.map((attachment) => {
const selected = selectedAttachmentIds.includes(attachment.id);
return (
<Chip
key={attachment.id}
label={attachment.fileName}
color={selected ? "primary" : "default"}
variant={selected ? "filled" : "outlined"}
onClick={() => setSelectedAttachmentIds((current) => current.includes(attachment.id) ? current.filter((id) => id !== attachment.id) : [...current, attachment.id].slice(-4))}
/>
);
})}
</Box>
</Box>
) : null}
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder={t("jobDetailsTailoredCvPlaceholder")} />
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>{t("jobDetailsLastUpdated", { value: job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : t("jobDetailsNotSavedYet") })}</Typography>
</Box>
+2
View File
@@ -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å",