feat(ui): redesign job workspace sections

This commit is contained in:
cesnimda
2026-10-07 01:58:31 +02:00
parent acd1d94684
commit ac6d78289d
123 changed files with 2842 additions and 205 deletions
@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useId, useState } from "react";
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
import {
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
Skeleton, Stack, TextField, Tooltip, Typography,
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputLabel, MenuItem, Paper, Select,
Stack, TextField, Tooltip, Typography,
} from "@mui/material";
import RichTextField from "./RichTextField";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
@@ -19,6 +19,9 @@ import { cvBuilderApi } from "../cvBuilder";
import { aiWorkspaceApi } from "../aiWorkspace";
import { useAccountPlan } from "../accountPlan";
import { useI18n } from "../i18n/I18nProvider";
import { PageSection } from "../layout/PageLayout";
import ViewStateNotice from "./ViewStateNotice";
import { extractApplicationAnswerDraft, removeApplicationAnswerDraft } from "../applicationDrafts";
// Phase 5.4 — Application Assets sections for the workspace.
//
@@ -70,24 +73,16 @@ function Shell({ title, subtitle, loading, error, children }: {
children: React.ReactNode;
}) {
return (
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
<Divider sx={{ my: 1.5 }} />
{loading ? (
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={44} />)}</Stack>
) : error ? (
<Alert severity="error">{error}</Alert>
) : (
children
)}
</Paper>
<PageSection surface title={title} description={subtitle} level="h3">
<ViewStateNotice title={title} loading={loading} error={error ? { kind: "error", message: error, retryable: false } : null} compact />
{!loading && !error && children}
</PageSection>
);
}
// ---------- CV ----------
export function ApplicationCvSection({ jobId }: { jobId: number }) {
export function ApplicationCvSection({ jobId, includePackages = true }: { jobId: number; includePackages?: boolean }) {
const { t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<ApplicationCv>(
() => applicationAssetsApi.cv(jobId),
@@ -166,7 +161,7 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
{data.attachedIsPublic ? ` · ${t("assetsPublic")}` : ""}
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} sx={{ minWidth: 0, maxWidth: "100%" }}>
<Button
size="small"
variant="outlined"
@@ -198,12 +193,14 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
</Shell>
<ApplicationTailoringSection jobId={jobId} />
<SubmittedPackagesSection jobId={jobId} />
{includePackages && <SubmittedPackagesSection jobId={jobId} />}
</Stack>
);
}
function SubmittedPackagesSection({ jobId }: { jobId: number }) {
export function SubmittedPackagesSection({ jobId, allowCapture = true }: { jobId: number; allowCapture?: boolean }) {
const titleId = useId();
const viewTrigger = useRef<HTMLButtonElement | null>(null);
const { language, t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<SubmittedPackage[]>(
() => applicationAssetsApi.submittedPackages(jobId),
@@ -260,31 +257,31 @@ function SubmittedPackagesSection({ jobId }: { jobId: number }) {
return (
<Shell title={t("assetsSubmittedPackages")} subtitle={t("assetsSubmittedPackagesSubtitle")} loading={loading} error={error}>
<Stack spacing={1.5}>
<Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={busy} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{allowCapture && <Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={busy} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{busy ? t("assetsPackageCapturing") : t("assetsPackageCapture")}
</Button>
</Button>}
{(data?.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">{t("assetsNoSubmittedPackages")}</Typography>
) : data?.map((item) => (
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={1}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1}>
<Stack spacing={1} sx={{ minWidth: 0 }}>
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="flex-start" gap={1}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t("assetsSubmittedPackageVersion", { version: item.version })}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(item.createdAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")}
</Typography>
</Box>
<Stack direction="row" gap={0.5} flexWrap="wrap" justifyContent="flex-end">
<Stack direction="row" gap={0.5} flexWrap="wrap" sx={{ minWidth: 0, maxWidth: "100%", "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Chip size="small" variant="outlined" color={item.hasCv ? "success" : "default"} label={item.hasCv ? `${item.cvVariantName ?? t("workspaceCv")} · v${item.cvVariantVersion ?? "—"}` : t("assetsPackageNoCv")} />
<Chip size="small" variant="outlined" color={item.hasCoverLetter ? "success" : "default"} label={item.hasCoverLetter ? t("workspaceCoverLetter") : t("assetsPackageNoCoverLetter")} />
</Stack>
</Stack>
<Button size="small" variant="outlined" disabled={detailBusy} onClick={() => void view(item)} sx={{ alignSelf: "flex-start" }}>
<Button size="small" variant="outlined" disabled={detailBusy} onClick={(event) => { viewTrigger.current = event.currentTarget; void view(item); }} sx={{ alignSelf: "flex-start" }}>
{t("assetsPackageView")}
</Button>
{item.attachments.map((attachment) => (
<Button key={attachment.id} size="small" variant="text" startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start" }}>
<Button key={attachment.id} size="small" variant="text" startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start", maxWidth: "100%", overflowWrap: "anywhere", textAlign: "left" }}>
{attachment.fileName} · {(attachment.fileSize / 1024).toFixed(1)} KB
</Button>
))}
@@ -292,15 +289,17 @@ function SubmittedPackagesSection({ jobId }: { jobId: number }) {
</Paper>
))}
</Stack>
<Dialog open={detail !== null} onClose={() => setDetail(null)} fullWidth maxWidth="lg" aria-labelledby="submitted-package-title">
<DialogTitle id="submitted-package-title">
<Dialog open={detail !== null} onClose={() => setDetail(null)} fullWidth maxWidth="lg" aria-labelledby={titleId} slotProps={{ transition: { onExited: () => viewTrigger.current?.focus() } }}>
<DialogTitle id={titleId}>
{detail ? t("assetsSubmittedPackageVersion", { version: detail.summary.version }) : t("assetsSubmittedPackages")}
</DialogTitle>
<DialogContent dividers>
<Alert severity="info" sx={{ mb: 2 }}>{t("workspaceFrozenHelp")}</Alert>
<Stack spacing={2.5}>
{detail?.renderedCvHtml ? (
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCv")}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1, overflowWrap: "anywhere" }}>{detail.summary.cvVariantName} · {t("assetsThemeVersion", { theme: detail.summary.cvThemeId ?? "—", version: detail.summary.cvVariantVersion ?? "—" })}</Typography>
<Box component="iframe" title={t("assetsPackageCvPreview")} srcDoc={detail.renderedCvHtml} sandbox="" sx={{ width: "100%", height: { xs: 520, md: 760 }, border: 1, borderColor: "divider", bgcolor: "white" }} />
</Box>
) : <Alert severity="info">{t("assetsPackageNoCv")}</Alert>}
@@ -310,12 +309,19 @@ function SubmittedPackagesSection({ jobId }: { jobId: number }) {
{detail?.coverLetterText || t("assetsPackageNoCoverLetter")}
</Typography>
</Box>
{material && ["TailoredCvText", "RecruiterMessageDraft", "Notes"].map((key) => typeof material[key] === "string" && material[key] ? (
{material && ["TailoredCvText", "RecruiterMessageDraft"].map((key) => typeof material[key] === "string" && material[key] ? (
<Box key={key}>
<Typography variant="subtitle2">{t(`assetsPackage${key}` as "assetsPackageTailoredCvText")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{String(material[key])}</Typography>
</Box>
) : null)}
{material && typeof material.Notes === "string" && [
[t("workspaceNotes"), removeApplicationAnswerDraft(material.Notes)],
[t("assetsApplicationAnswer"), extractApplicationAnswerDraft(material.Notes)],
].map(([label, content]) => content ? <Box key={label}>
<Typography variant="subtitle2">{label}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{content}</Typography>
</Box> : null)}
</Stack>
</DialogContent>
<DialogActions><Button onClick={() => setDetail(null)}>{t("close")}</Button></DialogActions>
@@ -10,6 +10,7 @@ import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { getApiErrorMessage } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { PageSection } from "../layout/PageLayout";
import {
CHECKLIST_CATEGORIES, Checklist, ChecklistItem, applicationChecklistApi, checklistSystemTranslationToken,
} from "../applicationWorkspace";
@@ -98,25 +99,18 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
{progress && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" alignItems="baseline" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("checklistTitle")}</Typography>
<Typography variant="body2" color="text.secondary">
{t("checklistProgress", { completed: progress.completed, total: progress.total })}
</Typography>
</Stack>
<PageSection surface title={t("checklistTitle")} level="h3" description={t("checklistProgress", { completed: progress.completed, total: progress.total })}>
<LinearProgress
variant="determinate"
value={progress.percent}
aria-label={t("checklistCompletion")}
sx={{ height: 8, borderRadius: 4 }}
/>
</Paper>
</PageSection>
)}
{grouped.map((group) => (
<Paper key={group.key} sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="overline" color="text.secondary">{checklistCategoryLabel(t, group.key)}</Typography>
<PageSection key={group.key} surface title={checklistCategoryLabel(t, group.key)} level="h3">
<Stack sx={{ mt: 0.5 }}>
{group.items.map((item) => {
const display = checklistItemDisplay(t, item);
@@ -143,7 +137,7 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
sx={{
fontWeight: 600,
textDecoration: item.status === "done" ? "line-through" : "none",
color: item.status === "done" ? "text.disabled" : "text.primary",
color: item.status === "done" ? "text.secondary" : "text.primary",
}}
>
{display.title}
@@ -182,11 +176,11 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
);
})}
</Stack>
</Paper>
</PageSection>
))}
<Paper component="form" onSubmit={add} sx={{ p: 2, borderRadius: 3 }}>
<Stack direction="row" spacing={1}>
<Paper variant="outlined" component="form" onSubmit={add} sx={{ p: 2, borderRadius: 1 }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<TextField
fullWidth
size="small"
@@ -195,7 +189,7 @@ export default function ApplicationChecklist({ jobId, onChanged }: { jobId: numb
disabled={busy}
onChange={(e) => setDraft(e.target.value)}
/>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()}>{t("checklistAdd")}</Button>
<Button type="submit" variant="contained" disabled={busy || !draft.trim()} sx={{ flexShrink: 0, whiteSpace: "nowrap" }}>{t("checklistAdd")}</Button>
</Stack>
</Paper>
</Stack>
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from "react";
import {
Alert, Box, Chip, Divider, LinearProgress, Paper, Skeleton, Stack, ToggleButton,
Alert, Box, Chip, LinearProgress, Stack, ToggleButton,
ToggleButtonGroup, Typography,
} from "@mui/material";
@@ -10,6 +10,8 @@ import {
CareerMatch, JobAnalysis, Timeline, applicationIntelligenceApi,
} from "../applicationWorkspace";
import { useI18n } from "../i18n/I18nProvider";
import { PageSection } from "../layout/PageLayout";
import ViewStateNotice from "./ViewStateNotice";
// Phase 5.3 — Application Intelligence sections for the workspace.
//
@@ -62,20 +64,14 @@ function SectionShell({ title, subtitle, loading, error, empty, emptyText, child
children: React.ReactNode;
}) {
return (
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
<Divider sx={{ my: 1.5 }} />
{loading ? (
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={40} />)}</Stack>
) : error ? (
<Alert severity="error">{error}</Alert>
) : empty ? (
<PageSection surface title={title} description={subtitle} level="h3">
<ViewStateNotice title={title} loading={loading} error={error ? { kind: "error", message: error, retryable: false } : null} compact />
{!loading && !error && (empty ? (
<Typography variant="body2" color="text.secondary">{emptyText}</Typography>
) : (
children
)}
</Paper>
))}
</PageSection>
);
}
@@ -123,11 +119,10 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
return (
<Stack spacing={2}>
{hasEvents && (data?.milestones.length ?? 0) > 0 && (
<Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("intelligenceMilestones")}</Typography>
<PageSection surface title={t("intelligenceMilestones")} level="h3">
<Stack spacing={0.75}>
{(data?.milestones ?? []).map((m) => (
<Stack key={m.id} direction="row" spacing={1} justifyContent="space-between" alignItems="baseline">
<Stack key={m.id} direction={{ xs: "column", sm: "row" }} spacing={0.5} justifyContent="space-between" alignItems="baseline">
<Typography variant="body2" sx={{ fontWeight: 600 }}>{m.summary}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(m.at).toLocaleDateString(language === "nb" ? "nb-NO" : "en")}
@@ -135,7 +130,7 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
</Stack>
))}
</Stack>
</Paper>
</PageSection>
)}
<SectionShell
@@ -173,11 +168,12 @@ export function ApplicationTimeline({ jobId }: { jobId: number }) {
(data?.days ?? []).map((day) => (
<Box key={day.date}>
<Typography variant="caption" sx={{ fontWeight: 800, textTransform: "uppercase", letterSpacing: ".06em", color: "text.secondary" }}>
{day.label}
{new Date(`${day.date.slice(0, 10)}T12:00:00`).toLocaleDateString(language === "nb" ? "nb-NO" : "en", { dateStyle: "long" })}
</Typography>
<Stack spacing={1} sx={{ mt: 0.75, pl: 1.5, borderLeft: "2px solid", borderColor: "divider" }}>
<Stack component="ol" spacing={1.5} sx={{ mt: 0.75, mb: 0, pl: 1.5, listStyle: "none", borderLeft: "2px solid", borderColor: "divider" }}>
{day.events.map((e) => (
<Box key={e.id} sx={{ pl: 1.5 }}>
<Box component="li" key={e.id} sx={{ pl: 1, overflowWrap: "anywhere" }}>
<Typography component="time" dateTime={e.at} variant="caption" color="text.secondary">{new Date(e.at).toLocaleTimeString(language === "nb" ? "nb-NO" : "en", { hour: "2-digit", minute: "2-digit", timeZone: "UTC", timeZoneName: "short" })} · {timelineCategoryLabel(t, e.category)}</Typography>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap">
<Typography variant="body2" sx={{ fontWeight: e.isMilestone ? 700 : 500 }}>
{e.summary}
+20 -20
View File
@@ -35,6 +35,7 @@ import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import { useI18n } from "../i18n/I18nProvider";
import { useDialogActions } from "../dialogs";
import ViewStateNotice from "./ViewStateNotice";
interface AttachmentItem {
id: number;
@@ -85,9 +86,11 @@ function purposeLabel(purpose: string | null | undefined, t: (key: any) => strin
export default function Attachments({ jobId }: { jobId: number }) {
const labelId = useId();
const { toast } = useToast();
const { t } = useI18n();
const { language, t } = useI18n();
const { confirmAction, promptForValue } = useDialogActions();
const [items, setItems] = useState<AttachmentItem[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [previewOpen, setPreviewOpen] = useState(false);
const [preview, setPreview] = useState<{ url: string; type: string; name: string } | null>(null);
const [dragActive, setDragActive] = useState(false);
@@ -95,13 +98,17 @@ export default function Attachments({ jobId }: { jobId: number }) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<AttachmentItem[]>(`/attachments/${jobId}`);
setItems(res.data);
} catch {
// non-fatal
setLoadError(null);
} catch (error) {
setLoadError(getApiErrorMessage(error, t("attachmentsLoadFailed")));
} finally {
setLoading(false);
}
}, [jobId]);
}, [jobId, t]);
useEffect(() => {
void load();
@@ -253,11 +260,10 @@ export default function Attachments({ jobId }: { jobId: number }) {
sx={{
mb: 2,
p: 2,
borderRadius: 3,
borderRadius: 1,
border: "1px dashed",
borderColor: dragActive ? "primary.main" : "divider",
backgroundColor: dragActive ? "action.hover" : "background.paper",
transition: "all 0.2s ease",
textAlign: "center",
}}
>
@@ -268,14 +274,17 @@ export default function Attachments({ jobId }: { jobId: number }) {
{uploading ? <LinearProgress sx={{ mt: 1.5, borderRadius: 999 }} /> : null}
</Box>
<TableContainer sx={{ borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Table size="small">
<ViewStateNotice title={t("attachmentsLoadFailed")} loading={loading} error={loadError ? { kind: "error", message: loadError, retryable: true } : null} onRetry={load} compact />
{!loading && !loadError && items.length === 0 && <Alert severity="info" variant="outlined">{t("attachmentsEmpty")}</Alert>}
{items.length > 0 && <TableContainer role="region" tabIndex={0} aria-label={t("attachmentsTitle", { count })} sx={{ maxWidth: "100%", borderRadius: 1, border: 1, borderColor: "divider", overflowX: "auto" }}>
<Table size="small" aria-label={t("workspaceCurrentDocuments")} sx={{ minWidth: 900 }}>
<TableHead>
<TableRow>
<TableCell>{t("attachmentsName")}</TableCell>
<TableCell sx={{ width: 140 }}>{t("attachmentsKind")}</TableCell>
<TableCell sx={{ width: 120 }}>{t("attachmentsType")}</TableCell>
<TableCell sx={{ width: 90 }}>{t("attachmentsSize")}</TableCell>
<TableCell>{t("attachmentsUploaded")}</TableCell>
<TableCell sx={{ width: 160 }}>{t("attachmentsPurpose")}</TableCell>
<TableCell sx={{ width: 110 }}>{t("attachmentsAiUse")}</TableCell>
<TableCell sx={{ width: 170 }}>{t("attachmentsActions")}</TableCell>
@@ -288,14 +297,14 @@ export default function Attachments({ jobId }: { jobId: number }) {
return (
<TableRow key={a.id} hover>
<TableCell>
<Button size="small" variant="text" onClick={() => void download(a)} sx={{ fontWeight: 800, textTransform: "none" }}>
<Button size="small" variant="text" onClick={() => void download(a)} sx={{ fontWeight: 700, textTransform: "none", textAlign: "left", overflowWrap: "anywhere" }}>
{a.fileName}
</Button>
</TableCell>
<TableCell>{kind}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{a.fileType ? a.fileType.replace("application/", "") : ""}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{a.fileSize ? fmtSize(a.fileSize) : ""}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString() : ""}</TableCell>
<TableCell sx={{ color: "text.secondary" }}>{a.uploadDate ? new Date(a.uploadDate).toLocaleString(language === "nb" ? "nb-NO" : "en") : ""}</TableCell>
<TableCell>
<FormControl size="small" fullWidth>
<InputLabel id={`${labelId}-${a.id}`}>{t("attachmentsPurpose")}</InputLabel>
@@ -337,18 +346,9 @@ export default function Attachments({ jobId }: { jobId: number }) {
);
})}
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={8}>
<Alert severity="info" variant="outlined">
{t("attachmentsEmpty")}
</Alert>
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</TableContainer>
</TableContainer>}
<Dialog open={previewOpen} onClose={() => setPreviewOpen(false)} fullWidth maxWidth="md">
<DialogTitle>{preview?.name ? t("attachmentsPreviewTitle", { name: preview.name }) : t("attachmentsPreview")}</DialogTitle>
@@ -43,6 +43,7 @@ import {
} from "../types";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
import ViewStateNotice from "./ViewStateNotice";
function parseRawEmail(raw: string): { subject?: string; date?: string; from?: string; to?: string; body: string } {
const lines = raw.replace(/\r\n/g, "\n").split("\n");
@@ -127,6 +128,8 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
return label;
};
const [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
const [messagesLoading, setMessagesLoading] = useState(true);
const [messagesError, setMessagesError] = useState<string | null>(null);
const [from, setFrom] = useState<"Me" | "Company">("Me");
const [text, setText] = useState("");
const scrollRef = useRef<HTMLDivElement | null>(null);
@@ -154,9 +157,18 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
const autoRefreshKeyRef = useRef<string | null>(null);
const load = useCallback(async () => {
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
setMessages(res.data);
}, [jobId]);
setMessagesLoading(true);
try {
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
setMessages(res.data);
setMessagesError(null);
} catch (error) {
setMessagesError(getApiErrorMessage(error, t("correspondenceLoadFailed")));
throw error; // Existing mutation callers still handle their own operation failure.
} finally {
setMessagesLoading(false);
}
}, [jobId, t]);
const loadGmailStatus = useCallback(async () => {
try {
@@ -242,7 +254,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
}, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, t, toast]);
useEffect(() => {
void load();
void load().catch(() => undefined);
}, [load]);
useEffect(() => {
@@ -460,8 +472,9 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
return (
<Box>
<Paper ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.45)" : "rgba(255,255,255,0.75)", backdropFilter: "blur(8px)" }}>
{messages.length === 0 ? (
<ViewStateNotice title={t("correspondenceLoadFailed")} loading={messagesLoading} error={messagesError ? { kind: "error", message: messagesError, retryable: true } : null} onRetry={() => load().catch(() => undefined)} compact />
<Paper variant="outlined" ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", bgcolor: "background.paper", borderRadius: 1 }}>
{messages.length === 0 && !messagesLoading && !messagesError ? (
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("correspondenceNoMessages")}</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
@@ -470,7 +483,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
const accent = isMe ? theme.palette.primary.main : theme.palette.warning.main;
return (
<Box key={m.id} sx={{ display: "flex", justifyContent: isMe ? "flex-end" : "flex-start" }}>
<Box sx={{ maxWidth: "80%", borderRadius: 3, p: 1.25, border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`, background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1), color: "text.primary" }}>
<Box sx={{ maxWidth: { xs: "100%", sm: "85%" }, minWidth: 0, overflowWrap: "anywhere", borderRadius: 1, p: 1.25, border: "1px solid", borderColor: "divider", borderLeft: `3px solid ${accent}`, bgcolor: "background.default", color: "text.primary", "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
{(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? (
@@ -498,7 +511,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
)}
</Paper>
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 1, border: 1, borderColor: "divider", bgcolor: "background.default", "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 24 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
<Typography variant="overline">{t("correspondenceLinkedContinuity")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.25 }}>
{t("correspondenceLinkedContinuityHelp")}
@@ -548,7 +561,7 @@ export default function Correspondence({ jobId, jobContext }: { jobId: number; j
<Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>{t("correspondenceImportEmail")}</Button>
<TextField label={t("correspondenceLogNoteOrMessage")} value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: "1 1 320px" }} helperText={t("correspondenceCharacters", { count: text.length })} />
<TextField label={t("correspondenceLogNoteOrMessage")} value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: { xs: "1 1 100%", sm: "1 1 320px" }, minWidth: 0 }} helperText={t("correspondenceCharacters", { count: text.length })} />
<Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
</Box>
@@ -1,8 +1,8 @@
import React, { useCallback, useEffect, useState } from "react";
import {
Alert, Box, Button, Checkbox, Chip, Divider, IconButton, LinearProgress, MenuItem, Paper,
Skeleton, Stack, TextField, Tooltip, Typography,
Alert, Box, Button, Checkbox, Chip, IconButton, LinearProgress, MenuItem, Paper,
Stack, TextField, Tooltip, Typography,
} from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
@@ -16,6 +16,8 @@ import { aiWorkspaceApi } from "../aiWorkspace";
import { useAccountPlan } from "../accountPlan";
import { useI18n } from "../i18n/I18nProvider";
import { useConfirm } from "../confirm";
import { PageSection } from "../layout/PageLayout";
import ViewStateNotice from "./ViewStateNotice";
// Phase 5.5 — Interview preparation and follow-up.
//
@@ -31,18 +33,10 @@ function Shell({ title, subtitle, loading, error, children }: {
children: React.ReactNode;
}) {
return (
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{title}</Typography>
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
<Divider sx={{ my: 1.5 }} />
{loading ? (
<Stack spacing={1}>{[0, 1, 2].map((i) => <Skeleton key={i} variant="rounded" height={44} />)}</Stack>
) : error ? (
<Alert severity="error">{error}</Alert>
) : (
children
)}
</Paper>
<PageSection surface title={title} description={subtitle} level="h3">
<ViewStateNotice title={title} loading={loading} error={error ? { kind: "error", message: error, retryable: false } : null} compact />
{!loading && !error && children}
</PageSection>
);
}
@@ -367,6 +361,7 @@ function PrepRow({ jobId, item, busy, onChanged, onError }: {
fullWidth
size="small"
placeholder={t("interviewPrepAnswerPlaceholder")}
inputProps={{ "aria-label": t("interviewPrepAnswerFor", { title: item.title }) }}
value={content}
disabled={busy || saving}
onChange={(e) => setDraft(e.target.value)}