Add reminder emails and AI CV improvement tools
This commit is contained in:
@@ -41,6 +41,7 @@ interface Props {
|
||||
open: boolean;
|
||||
jobId: number | null;
|
||||
onClose: () => void;
|
||||
initialTab?: number;
|
||||
}
|
||||
|
||||
function statusChipColor(status: string): "default" | "primary" | "warning" | "error" | "success" {
|
||||
@@ -69,7 +70,7 @@ function copyLines(items: string[]) {
|
||||
return navigator.clipboard.writeText(items.map((item) => `• ${item}`).join("\n"));
|
||||
}
|
||||
|
||||
export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0 }: Props) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { confirmAction } = useDialogActions();
|
||||
@@ -100,7 +101,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId) return;
|
||||
setTab(0);
|
||||
setTab(Math.max(0, Math.min(8, initialTab)));
|
||||
setFollowUpDraft(null);
|
||||
setCandidateFit(null);
|
||||
setInterviewPrep(null);
|
||||
@@ -113,7 +114,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
||||
});
|
||||
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]);
|
||||
}, [open, jobId, initialTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !jobId || tab !== 4 || followUpDraft) return;
|
||||
|
||||
@@ -147,6 +147,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
const { companies } = useCompanies();
|
||||
const [companyFilterId, setCompanyFilterId] = useState<number | "All">("All");
|
||||
const [detailsJobId, setDetailsJobId] = useState<number | null>(null);
|
||||
const [detailsInitialTab, setDetailsInitialTab] = useState(0);
|
||||
const [editJobId, setEditJobId] = useState<number | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const [statusAnchor, setStatusAnchor] = useState<null | HTMLElement>(null);
|
||||
@@ -179,11 +180,14 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
useEffect(() => {
|
||||
const paramsSearch = new URLSearchParams(location.search);
|
||||
const openId = Number(paramsSearch.get("open") || 0);
|
||||
const tabIndex = Number(paramsSearch.get("tab") || 0);
|
||||
if (!openId || jobs.length === 0) return;
|
||||
const job = jobs.find((j) => j.id === openId);
|
||||
if (!job) return;
|
||||
setDetailsJobId(openId);
|
||||
setDetailsInitialTab(Number.isFinite(tabIndex) ? Math.max(0, Math.min(8, tabIndex)) : 0);
|
||||
paramsSearch.delete("open");
|
||||
paramsSearch.delete("tab");
|
||||
navigate({ pathname: location.pathname, search: paramsSearch.toString() ? `?${paramsSearch.toString()}` : "" }, { replace: true });
|
||||
}, [jobs, location.pathname, location.search, navigate]);
|
||||
|
||||
@@ -409,7 +413,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
<TablePagination component="div" count={total} page={page} onPageChange={(_, next) => setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} />
|
||||
</Paper>
|
||||
|
||||
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} onClose={() => setDetailsJobId(null)} />
|
||||
<JobDetailsDialog open={detailsJobId !== null} jobId={detailsJobId} initialTab={detailsInitialTab} onClose={() => { setDetailsJobId(null); setDetailsInitialTab(0); }} />
|
||||
<EditJobDialog open={editJobId !== null} jobId={editJobId} onClose={() => setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} />
|
||||
<Menu anchorEl={statusAnchor} open={Boolean(statusAnchor)} onClose={() => { setStatusAnchor(null); setStatusJobId(null); }}>
|
||||
{(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => <MenuItem key={s} onClick={() => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })}</MenuItem>)}
|
||||
|
||||
@@ -175,6 +175,10 @@ export const translations = {
|
||||
profileMasterCv: "Master CV",
|
||||
profileMasterCvBody: "Upload a PDF, DOCX, plain text file, markdown file, or image scan. The AI service extracts text where possible and falls back to OCR for supported scanned files.",
|
||||
profileUploadCv: "Upload CV",
|
||||
profileCvImprove: "Improve CV text",
|
||||
profileCvImproving: "Improving CV...",
|
||||
profileCvImproved: "CV text improved.",
|
||||
profileCvImproveFailed: "Failed to improve CV text.",
|
||||
profileUploading: "Uploading...",
|
||||
profileCopyCvText: "Copy CV text",
|
||||
profileCvUploaded: "CV uploaded and processed.",
|
||||
@@ -896,6 +900,10 @@ export const translations = {
|
||||
profileMasterCv: "Hoved-CV",
|
||||
profileMasterCvBody: "Last opp en PDF, DOCX, ren tekstfil, markdown-fil eller et bildeskann. AI-tjenesten henter ut tekst der det er mulig og faller tilbake til OCR for støttede skannede filer.",
|
||||
profileUploadCv: "Last opp CV",
|
||||
profileCvImprove: "Forbedre CV-tekst",
|
||||
profileCvImproving: "Forbedrer CV...",
|
||||
profileCvImproved: "CV-tekst forbedret.",
|
||||
profileCvImproveFailed: "Kunne ikke forbedre CV-tekst.",
|
||||
profileUploading: "Laster opp...",
|
||||
profileCopyCvText: "Kopier CV-tekst",
|
||||
profileCvUploaded: "CV lastet opp og behandlet.",
|
||||
|
||||
@@ -51,6 +51,7 @@ export default function ProfilePage() {
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadingCv, setUploadingCv] = useState(false);
|
||||
const [improvingCv, setImprovingCv] = useState(false);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [cropOpen, setCropOpen] = useState(false);
|
||||
@@ -247,9 +248,28 @@ export default function ProfilePage() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button variant="outlined" disabled={!isLocal || uploadingCv} onClick={() => cvInputRef.current?.click()}>
|
||||
<Button variant="outlined" disabled={!isLocal || uploadingCv || improvingCv} onClick={() => cvInputRef.current?.click()}>
|
||||
{uploadingCv ? t("profileUploading") : t("profileUploadCv")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!isLocal || !profileCvText.trim() || uploadingCv || improvingCv}
|
||||
onClick={async () => {
|
||||
setImprovingCv(true);
|
||||
try {
|
||||
const res = await api.post<{ text?: string }>("/profile-cv/improve");
|
||||
if (res.data?.text) setProfileCvText(res.data.text);
|
||||
await loadProfile();
|
||||
toast(t("profileCvImproved"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvImproveFailed")), "error");
|
||||
} finally {
|
||||
setImprovingCv(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{improvingCv ? t("profileCvImproving") : t("profileCvImprove")}
|
||||
</Button>
|
||||
<Button variant="text" disabled={!profileCvText.trim()} onClick={() => navigator.clipboard.writeText(profileCvText)}>
|
||||
{t("profileCopyCvText")}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user