ce76046a29
- consolidate API ownership and remove dead vendor code - add Stripe billing, learning paths, and public CV hardening - add migration, recovery, security, audit, and browser gates
213 lines
12 KiB
TypeScript
213 lines
12 KiB
TypeScript
import React from "react";
|
|
import { Box, Button, Chip, CircularProgress, TextField, Typography } from "@mui/material";
|
|
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import { MatchScore } from "../types";
|
|
|
|
function copyLines(items: string[]) {
|
|
void navigator.clipboard.writeText(items.join("\n"));
|
|
}
|
|
|
|
export function MatchScoreCard({ score, loading, onUpdateLearningRecommendation }: {
|
|
score: MatchScore | null;
|
|
loading: boolean;
|
|
onUpdateLearningRecommendation?: (id: number, status: "done" | "dismissed") => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
|
|
if (loading && !score) {
|
|
return (
|
|
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", gap: 1.5 }}>
|
|
<CircularProgress size={18} />
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (!score) return null;
|
|
|
|
const color: "success" | "warning" | "error" | "inherit" =
|
|
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
|
|
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
|
|
const learningRecommendations = score.learningRecommendations ?? [];
|
|
const visibleRecommendations = learningRecommendations.filter(item => item.status !== "dismissed");
|
|
const dismissedCount = learningRecommendations.length - visibleRecommendations.length;
|
|
|
|
return (
|
|
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, 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={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
|
|
{score.hasEnoughSignal ? (
|
|
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
|
|
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
|
|
<CircularProgress
|
|
variant="determinate"
|
|
value={score.score}
|
|
size={92}
|
|
thickness={4}
|
|
aria-hidden="true"
|
|
color={color === "inherit" ? "primary" : color}
|
|
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
|
|
/>
|
|
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
|
|
</Box>
|
|
</Box>
|
|
) : (
|
|
<Typography variant="h4" sx={{ fontWeight: 800 }}>—</Typography>
|
|
)}
|
|
<Box sx={{ flex: 1, minWidth: 200 }}>
|
|
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
|
|
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
|
|
</Box>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
|
|
</Box>
|
|
</Box>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
|
<Box>
|
|
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
|
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
|
|
</Box>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
|
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
{score.sectionCoverage.length ? (
|
|
<Box sx={{ mt: 1.5 }}>
|
|
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
|
|
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
|
|
</Box>
|
|
</Box>
|
|
) : null}
|
|
{learningRecommendations.length ? (
|
|
<Box sx={{ mt: 1.5 }}>
|
|
<Typography variant="overline">{t("matchScoreLearningPath")}</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>
|
|
{t("matchScoreLearningPathHint")}
|
|
</Typography>
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
|
{visibleRecommendations.map(item => (
|
|
<Box key={item.id} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
|
<Chip size="small" label={item.keyword} color={item.status === "done" ? "success" : "warning"} variant={item.status === "done" ? "filled" : "outlined"} />
|
|
{item.status === "done" ? <Typography variant="caption" color="success.main">{t("matchScoreLearned")}</Typography> : (
|
|
<Box sx={{ display: "flex", gap: 0.75 }}>
|
|
<Button size="small" onClick={() => onUpdateLearningRecommendation?.(item.id, "done")}>{t("matchScoreMarkLearned")}</Button>
|
|
<Button size="small" color="inherit" onClick={() => onUpdateLearningRecommendation?.(item.id, "dismissed")}>{t("matchScoreDismiss")}</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
))}
|
|
{dismissedCount ? <Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreDismissedCount", { count: dismissedCount })}</Typography> : null}
|
|
</Box>
|
|
</Box>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<Box>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
|
<Typography variant="overline">{title}</Typography>
|
|
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
|
|
{items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) {
|
|
return (
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
|
<ListCard title={leftTitle} items={leftItems} />
|
|
<ListCard title={rightTitle} items={rightItems} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function ListCard({ title, items, subtitle }: { title: string; items: string[]; subtitle?: string }) {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<Box sx={{ p: 1.5, borderRadius: 3, 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={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
|
<Box>
|
|
<Typography variant="overline">{title}</Typography>
|
|
{subtitle ? <Typography variant="caption" sx={{ display: "block", color: "text.secondary" }}>{subtitle}</Typography> : null}
|
|
</Box>
|
|
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
|
|
</Box>
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
|
{items.length ? items.map((item, index) => <Typography key={`${title}-${index}-${item}`} sx={{ color: "text.primary" }}>• {item}</Typography>) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function WorkspaceDraftCard({ title, value, onChange, statusLabel, statusColor }: { title: string; value: string; onChange: (value: string) => void; statusLabel: string; statusColor: "default" | "success" | "warning" }) {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<Box sx={{ p: 1.5, borderRadius: 3, 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={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
|
<Typography variant="overline">{title}</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
|
|
<Chip size="small" color={statusColor} label={statusLabel} />
|
|
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
|
</Box>
|
|
</Box>
|
|
<TextField value={value} onChange={(e) => onChange(e.target.value)} multiline minRows={7} fullWidth />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
|
|
const { t } = useI18n();
|
|
const [value, setValue] = React.useState(content);
|
|
|
|
React.useEffect(() => {
|
|
setValue(content);
|
|
}, [content]);
|
|
|
|
return (
|
|
<Box sx={{ p: 1.5, borderRadius: 3, 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={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
|
<Typography variant="overline">{title}</Typography>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
|
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
|
|
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(value)}>{saving ? t("jobDetailsSaving") : t("save")}</Button> : null}
|
|
</Box>
|
|
</Box>
|
|
<TextField value={value} onChange={(e) => setValue(e.target.value)} multiline minRows={6} fullWidth />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) {
|
|
return (
|
|
<Box sx={{ border: "1px solid rgba(15,23,42,0.08)", borderRadius: 2, p: 1.25, background: "rgba(255,255,255,0.6)" }}>
|
|
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
|
{type}
|
|
{oldValue || newValue ? <span style={{ fontWeight: 700, opacity: 0.7 }}>{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})</span> : null}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
|
{at ? new Date(at).toLocaleString() : ""}
|
|
{note ? ` - ${note}` : ""}
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
}
|