feat(ui): redesign job workspace sections
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user