Translate correspondence flow and localize AI system details

This commit is contained in:
cesnimda
2026-03-23 20:20:06 +01:00
parent 653f713a78
commit b3cbaee16c
3 changed files with 213 additions and 73 deletions
@@ -31,6 +31,7 @@ import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types"; import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
import { useDialogActions } from "../dialogs"; import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
function parseRawEmail(raw: string): { subject?: string; date?: string; from?: string; to?: string; body: string } { function parseRawEmail(raw: string): { subject?: string; date?: string; from?: string; to?: string; body: string } {
const lines = raw.replace(/\r\n/g, "\n").split("\n"); const lines = raw.replace(/\r\n/g, "\n").split("\n");
@@ -80,6 +81,7 @@ function scoreMessage(message: GmailMessageSummary, query: string, messages: Cor
export default function Correspondence({ jobId }: { jobId: number }) { export default function Correspondence({ jobId }: { jobId: number }) {
const theme = useTheme(); const theme = useTheme();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n();
const { confirmAction } = useDialogActions(); const { confirmAction } = useDialogActions();
const [messages, setMessages] = useState<CorrespondenceMessage[]>([]); const [messages, setMessages] = useState<CorrespondenceMessage[]>([]);
const [from, setFrom] = useState<"Me" | "Company">("Me"); const [from, setFrom] = useState<"Me" | "Company">("Me");
@@ -157,18 +159,18 @@ export default function Correspondence({ jobId }: { jobId: number }) {
const data = event.data as { source?: string; status?: string; message?: string }; const data = event.data as { source?: string; status?: string; message?: string };
if (data?.source !== "jobtracker-gmail-oauth") return; if (data?.source !== "jobtracker-gmail-oauth") return;
if (data.status === "connected") { if (data.status === "connected") {
toast(data.message || "Gmail connected.", "success"); toast(data.message || t("googleLinkedSuccess"), "success");
void loadGmailStatus(); void loadGmailStatus();
setImportTab(1); setImportTab(1);
void loadGmailMessages(); void loadGmailMessages();
} else { } else {
toast(data.message || "Gmail connection failed.", "error"); toast(data.message || t("googleAuthFailed"), "error");
} }
}; };
window.addEventListener("message", onMessage); window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage);
}, [loadGmailMessages, loadGmailStatus, toast]); }, [loadGmailMessages, loadGmailStatus, t, toast]);
const canSend = useMemo(() => text.trim().length > 0, [text]); const canSend = useMemo(() => text.trim().length > 0, [text]);
@@ -213,7 +215,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
const importEmail = async () => { const importEmail = async () => {
const parsed = parseRawEmail(rawEmail); const parsed = parseRawEmail(rawEmail);
if (!parsed.body && !parsed.subject && !rawEmail.trim()) { if (!parsed.body && !parsed.subject && !rawEmail.trim()) {
toast("Paste an email first.", "error"); toast(t("addJobModalPasteUrlFirst"), "error");
return; return;
} }
@@ -229,7 +231,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
setImportOpen(false); setImportOpen(false);
setRawEmail(""); setRawEmail("");
await load(); await load();
toast("Email logged.", "success"); toast(t("correspondenceLogEmail"), "success");
} catch (error) { } catch (error) {
toast(getApiErrorMessage(error, "Failed to import email."), "error"); toast(getApiErrorMessage(error, "Failed to import email."), "error");
} }
@@ -250,14 +252,14 @@ export default function Correspondence({ jobId }: { jobId: number }) {
await api.delete("/gmail/connection"); await api.delete("/gmail/connection");
setGmailStatus({ connected: false }); setGmailStatus({ connected: false });
setGmailMessages([]); setGmailMessages([]);
toast("Gmail disconnected.", "success"); toast(t("googleUnlinked"), "success");
} catch (error) { } catch (error) {
toast(getApiErrorMessage(error, "Failed to disconnect Gmail."), "error"); toast(getApiErrorMessage(error, "Failed to disconnect Gmail."), "error");
} }
}; };
const deleteMessage = async (messageId: number) => { const deleteMessage = async (messageId: number) => {
if (!(await confirmAction("Remove this correspondence message?", { title: "Delete message", confirmLabel: "Delete", destructive: true }))) return; if (!(await confirmAction("Remove this correspondence message?", { title: "Delete message", confirmLabel: t("jobTableDeleteSelected"), destructive: true }))) return;
try { try {
await api.delete(`/correspondence/${messageId}`); await api.delete(`/correspondence/${messageId}`);
await load(); await load();
@@ -272,7 +274,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
setImportingMessageId(messageId); setImportingMessageId(messageId);
await api.post("/gmail/import", { jobApplicationId: jobId, messageId }); await api.post("/gmail/import", { jobApplicationId: jobId, messageId });
await load(); await load();
toast("Email imported from Gmail.", "success"); toast(t("correspondenceImportEmail"), "success");
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to import Gmail message."), "error"); toast(getApiErrorMessage(error, "Failed to import Gmail message."), "error");
} finally { } finally {
@@ -310,7 +312,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography> <Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}> <Typography variant="caption" sx={{ color: "text.secondary" }}>
{isMe ? "Me" : "Company"}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""} {isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
</Typography> </Typography>
<IconButton size="small" onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}> <IconButton size="small" onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}>
<DeleteOutlineIcon fontSize="small" /> <DeleteOutlineIcon fontSize="small" />
@@ -326,47 +328,47 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}>
<ToggleButtonGroup exclusive value={from} onChange={(_, v) => v && setFrom(v)} size="small"> <ToggleButtonGroup exclusive value={from} onChange={(_, v) => v && setFrom(v)} size="small">
<ToggleButton value="Me">Me</ToggleButton> <ToggleButton value="Me">{t("correspondenceMe")}</ToggleButton>
<ToggleButton value="Company">Company</ToggleButton> <ToggleButton value="Company">{t("correspondenceCompany")}</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
<Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>Import Email</Button> <Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>{t("correspondenceImportEmail")}</Button>
<TextField label="Log note or message" value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: "1 1 320px" }} helperText={`${text.length} characters`} /> <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 })} />
<Button variant="contained" onClick={send} disabled={!canSend}>Add</Button> <Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
</Box> </Box>
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md"> <Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
<DialogTitle>Import Email</DialogTitle> <DialogTitle>{t("correspondenceImportTitle")}</DialogTitle>
<DialogContent> <DialogContent>
<Tabs value={importTab} onChange={(_, v) => setImportTab(v)} sx={{ mb: 2 }}> <Tabs value={importTab} onChange={(_, v) => setImportTab(v)} sx={{ mb: 2 }}>
<Tab label="Paste email" /> <Tab label={t("correspondencePasteEmail")} />
<Tab label="Gmail" /> <Tab label={t("google")} />
</Tabs> </Tabs>
{importTab === 0 ? ( {importTab === 0 ? (
<> <>
<Typography sx={{ color: "text.secondary", mb: 1 }}>Paste raw email text (headers optional). We parse Subject and Date when present.</Typography> <Typography sx={{ color: "text.secondary", mb: 1 }}>{t("correspondencePasteEmailHelp")}</Typography>
<TextField multiline minRows={10} fullWidth value={rawEmail} onChange={(e) => setRawEmail(e.target.value)} placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."} /> <TextField multiline minRows={10} fullWidth value={rawEmail} onChange={(e) => setRawEmail(e.target.value)} placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."} />
</> </>
) : ( ) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center" }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Box> <Box>
<Typography sx={{ fontWeight: 800 }}>Google Gmail</Typography> <Typography sx={{ fontWeight: 800 }}>{t("correspondenceGoogleGmail")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Typography variant="body2" sx={{ color: "text.secondary" }}>
{gmailLoading ? "Checking connection..." : gmailStatus?.connected ? `Connected as ${gmailStatus.gmailAddress}` : "Connect your Gmail account to browse recent emails."} {gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{gmailStatus?.connected ? ( {gmailStatus?.connected ? (
<> <>
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>Refresh</Button> <Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceRefresh")}</Button>
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>Disconnect</Button> <Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button>
</> </>
) : ( ) : (
<Button variant="contained" onClick={() => void connectGmail()}>Connect Gmail</Button> <Button variant="contained" onClick={() => void connectGmail()}>{t("correspondenceConnectGmail")}</Button>
)} )}
</Box> </Box>
</Box> </Box>
@@ -379,8 +381,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
))} ))}
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<TextField label="Search Gmail" value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder="from:company@example.com OR interview" size="small" fullWidth /> <TextField label={t("correspondenceSearchGmail")} value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} size="small" fullWidth />
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>Search</Button> <Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceSearch")}</Button>
</Box> </Box>
{gmailStatus.lastSyncedAt ? <Chip label={`Last synced ${new Date(gmailStatus.lastSyncedAt).toLocaleString()}`} size="small" /> : null} {gmailStatus.lastSyncedAt ? <Chip label={`Last synced ${new Date(gmailStatus.lastSyncedAt).toLocaleString()}`} size="small" /> : null}
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}> <Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
@@ -409,7 +411,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}> <ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
<ListItemText <ListItemText
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || "(No subject)"}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>} primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || "(No subject)"}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>}
secondary={<Box sx={{ mt: 0.5 }}><Typography variant="body2" sx={{ color: "text.primary" }}>From: {message.from || "Unknown"}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>{message.snippet}</Typography></Box>} secondary={<Box sx={{ mt: 0.5 }}><Typography variant="body2" sx={{ color: "text.primary" }}>From: {message.from || t("correspondenceUnknown")}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>{message.snippet}</Typography></Box>}
/> />
<Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}> <Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}>
{importingMessageId === message.id ? "Importing..." : "Import"} {importingMessageId === message.id ? "Importing..." : "Import"}
@@ -429,8 +431,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
)} )}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setImportOpen(false)}>Close</Button> <Button onClick={() => setImportOpen(false)}>{t("correspondenceClose")}</Button>
{importTab === 0 ? <Button variant="contained" onClick={importEmail}>Log Email</Button> : null} {importTab === 0 ? <Button variant="contained" onClick={importEmail}>{t("correspondenceLogEmail")}</Button> : null}
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</Box> </Box>
+138
View File
@@ -277,6 +277,75 @@ export const translations = {
adminSystemRunningProbe: "Running probe...", adminSystemRunningProbe: "Running probe...",
adminSystemRefresh: "Refresh", adminSystemRefresh: "Refresh",
adminSystemRefreshing: "Refreshing...", adminSystemRefreshing: "Refreshing...",
adminSystemProvider: "Provider",
adminSystemTarget: "Target",
adminSystemConfigured: "Configured",
adminSystemCanConnect: "Can connect",
adminSystemUsesFileStorage: "Uses file storage",
adminSystemDataRoot: "Data root",
adminSystemDbPath: "DB path",
adminSystemDbFileExists: "DB file exists",
adminSystemDbSize: "DB size",
adminSystemJobs: "Jobs",
adminSystemDeletedJobs: "Deleted jobs",
adminSystemFramework: "Framework",
adminSystemOs: "OS",
adminSystemArchitecture: "Architecture",
adminSystemMachine: "Machine",
adminSystemContentRoot: "Content root",
adminSystemBuildStamp: "Build stamp",
adminSystemAuthRequired: "Auth required",
adminSystemJwtConfigured: "JWT key configured",
adminSystemGoogleConfigured: "Google login configured",
adminSystemGmailConfigured: "Gmail integration configured",
adminSystemFrom: "From",
adminSystemFromName: "From name",
adminSystemHost: "Host",
adminSystemPort: "Port",
adminSystemSsl: "SSL",
adminSystemModel: "Model",
adminSystemDevice: "Device",
adminSystemGpuAvailable: "GPU available",
adminSystemGpuName: "GPU name",
adminSystemHealthLatency: "Health latency",
adminSystemProbeLatency: "Probe latency",
adminSystemLastProbe: "Last probe",
adminSystemLastSuccessfulProbe: "Last successful probe",
adminSystemLastSummarizationSuccess: "Last summarization success",
adminSystemRequests: "Requests",
adminSystemCacheHits: "Cache hits",
adminSystemCacheMisses: "Cache misses",
adminSystemFailures: "Failures",
adminSystemProbeFailures: "Probe failures",
adminSystemAvgLatency: "Avg latency",
adminSystemOcrRequests: "OCR requests",
adminSystemOcrAvgLatency: "OCR avg latency",
adminSystemOcrUnavailable: "OCR unavailable",
adminSystemAiProbeFailed: "Failed to run AI service probe.",
correspondenceNoMessages: "No messages yet.",
correspondenceMe: "Me",
correspondenceCompany: "Company",
correspondenceImportEmail: "Import email",
correspondenceLogNoteOrMessage: "Log note or message",
correspondenceCharacters: "{count} characters",
correspondenceAdd: "Add",
correspondenceImportTitle: "Import email",
correspondencePasteEmail: "Paste email",
correspondencePasteEmailHelp: "Paste raw email text (headers optional). We parse Subject and Date when present.",
correspondenceGoogleGmail: "Google Gmail",
correspondenceCheckingConnection: "Checking connection...",
correspondenceConnectedAs: "Connected as {email}",
correspondenceConnectGmailHint: "Connect your Gmail account to browse recent emails.",
correspondenceRefresh: "Refresh",
correspondenceDisconnect: "Disconnect",
correspondenceConnectGmail: "Connect Gmail",
correspondenceSearchGmail: "Search Gmail",
correspondenceSearchGmailPlaceholder: "from:company@example.com OR interview",
correspondenceSearch: "Search",
correspondenceNoGmailMessages: "No Gmail messages found.",
correspondenceUnknown: "Unknown",
correspondenceClose: "Close",
correspondenceLogEmail: "Log email",
adminSystemEnvironment: "Environment", adminSystemEnvironment: "Environment",
adminSystemDatabase: "Database", adminSystemDatabase: "Database",
adminSystemConnected: "Connected", adminSystemConnected: "Connected",
@@ -697,6 +766,75 @@ export const translations = {
adminSystemRunningProbe: "Kjører probe...", adminSystemRunningProbe: "Kjører probe...",
adminSystemRefresh: "Oppdater", adminSystemRefresh: "Oppdater",
adminSystemRefreshing: "Oppdaterer...", adminSystemRefreshing: "Oppdaterer...",
adminSystemProvider: "Leverandør",
adminSystemTarget: "Mål",
adminSystemConfigured: "Konfigurert",
adminSystemCanConnect: "Kan koble til",
adminSystemUsesFileStorage: "Bruker fillagring",
adminSystemDataRoot: "Datarot",
adminSystemDbPath: "DB-sti",
adminSystemDbFileExists: "DB-fil finnes",
adminSystemDbSize: "DB-størrelse",
adminSystemJobs: "Jobber",
adminSystemDeletedJobs: "Slettede jobber",
adminSystemFramework: "Rammeverk",
adminSystemOs: "OS",
adminSystemArchitecture: "Arkitektur",
adminSystemMachine: "Maskin",
adminSystemContentRoot: "Innholdsrot",
adminSystemBuildStamp: "Byggestempel",
adminSystemAuthRequired: "Autentisering kreves",
adminSystemJwtConfigured: "JWT-nøkkel konfigurert",
adminSystemGoogleConfigured: "Google-innlogging konfigurert",
adminSystemGmailConfigured: "Gmail-integrasjon konfigurert",
adminSystemFrom: "Fra",
adminSystemFromName: "Fra-navn",
adminSystemHost: "Vert",
adminSystemPort: "Port",
adminSystemSsl: "SSL",
adminSystemModel: "Modell",
adminSystemDevice: "Enhet",
adminSystemGpuAvailable: "GPU tilgjengelig",
adminSystemGpuName: "GPU-navn",
adminSystemHealthLatency: "Helselatens",
adminSystemProbeLatency: "Probelatens",
adminSystemLastProbe: "Siste probe",
adminSystemLastSuccessfulProbe: "Siste vellykkede probe",
adminSystemLastSummarizationSuccess: "Siste vellykkede oppsummering",
adminSystemRequests: "Forespørsler",
adminSystemCacheHits: "Cache-treff",
adminSystemCacheMisses: "Cache-miss",
adminSystemFailures: "Feil",
adminSystemProbeFailures: "Probefeil",
adminSystemAvgLatency: "Snittlatens",
adminSystemOcrRequests: "OCR-forespørsler",
adminSystemOcrAvgLatency: "OCR snittlatens",
adminSystemOcrUnavailable: "OCR utilgjengelig",
adminSystemAiProbeFailed: "Kunne ikke kjøre AI-tjenesteprobe.",
correspondenceNoMessages: "Ingen meldinger ennå.",
correspondenceMe: "Meg",
correspondenceCompany: "Selskap",
correspondenceImportEmail: "Importer e-post",
correspondenceLogNoteOrMessage: "Loggfør notat eller melding",
correspondenceCharacters: "{count} tegn",
correspondenceAdd: "Legg til",
correspondenceImportTitle: "Importer e-post",
correspondencePasteEmail: "Lim inn e-post",
correspondencePasteEmailHelp: "Lim inn rå e-posttekst (overskrifter valgfritt). Vi tolker emne og dato når de finnes.",
correspondenceGoogleGmail: "Google Gmail",
correspondenceCheckingConnection: "Sjekker tilkobling...",
correspondenceConnectedAs: "Tilkoblet som {email}",
correspondenceConnectGmailHint: "Koble til Gmail-kontoen din for å bla gjennom nylige e-poster.",
correspondenceRefresh: "Oppdater",
correspondenceDisconnect: "Koble fra",
correspondenceConnectGmail: "Koble til Gmail",
correspondenceSearchGmail: "Søk i Gmail",
correspondenceSearchGmailPlaceholder: "from:company@example.com OR interview",
correspondenceSearch: "Søk",
correspondenceNoGmailMessages: "Ingen Gmail-meldinger funnet.",
correspondenceUnknown: "Ukjent",
correspondenceClose: "Lukk",
correspondenceLogEmail: "Loggfør e-post",
adminSystemEnvironment: "Miljø", adminSystemEnvironment: "Miljø",
adminSystemDatabase: "Database", adminSystemDatabase: "Database",
adminSystemConnected: "Tilkoblet", adminSystemConnected: "Tilkoblet",
+45 -45
View File
@@ -247,34 +247,34 @@ export default function AdminSystemPage() {
<Paper sx={{ p: 2, borderRadius: 3 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemDatabaseStorage")}</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemDatabaseStorage")}</Typography>
<Stack spacing={0.75}> <Stack spacing={0.75}>
<DetailRow label="Provider" value={status?.database.provider || "-"} /> <DetailRow label={t("adminSystemProvider")} value={status?.database.provider || "-"} />
<DetailRow label="Target" value={status?.database.target || "-"} /> <DetailRow label={t("adminSystemTarget")} value={status?.database.target || "-"} />
<DetailRow label="Configured" value={status?.database.looksConfigured ? "Yes" : "No"} /> <DetailRow label={t("adminSystemConfigured")} value={status?.database.looksConfigured ? "Yes" : "No"} />
<DetailRow label="Can connect" value={status?.database.canConnect ? "Yes" : "No"} /> <DetailRow label={t("adminSystemCanConnect")} value={status?.database.canConnect ? "Yes" : "No"} />
<DetailRow label="Uses file storage" value={status?.database.usesFileStorage ? "Yes" : "No"} /> <DetailRow label={t("adminSystemUsesFileStorage")} value={status?.database.usesFileStorage ? "Yes" : "No"} />
<DetailRow label="Data root" value={status?.storage.dataRoot || "-"} /> <DetailRow label={t("adminSystemDataRoot")} value={status?.storage.dataRoot || "-"} />
<DetailRow label="DB path" value={status?.storage.dbPath || "-"} /> <DetailRow label={t("adminSystemDbPath")} value={status?.storage.dbPath || "-"} />
<DetailRow label="DB file exists" value={status?.storage.dbExists ? "Yes" : "No"} /> <DetailRow label={t("adminSystemDbFileExists")} value={status?.storage.dbExists ? "Yes" : "No"} />
<DetailRow label="DB size" value={formatBytes(status?.storage.dbSizeBytes)} /> <DetailRow label={t("adminSystemDbSize")} value={formatBytes(status?.storage.dbSizeBytes)} />
<DetailRow label="Companies" value={status?.storage.companyCount ?? 0} /> <DetailRow label="Companies" value={status?.storage.companyCount ?? 0} />
<DetailRow label="Jobs" value={status?.storage.jobCount ?? 0} /> <DetailRow label={t("adminSystemJobs")} value={status?.storage.jobCount ?? 0} />
<DetailRow label="Deleted jobs" value={status?.storage.deletedCount ?? 0} /> <DetailRow label={t("adminSystemDeletedJobs")} value={status?.storage.deletedCount ?? 0} />
</Stack> </Stack>
</Paper> </Paper>
<Paper sx={{ p: 2, borderRadius: 3 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemRuntimeAuth")}</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemRuntimeAuth")}</Typography>
<Stack spacing={0.75}> <Stack spacing={0.75}>
<DetailRow label="Framework" value={status?.runtime.framework || "-"} /> <DetailRow label={t("adminSystemFramework")} value={status?.runtime.framework || "-"} />
<DetailRow label="OS" value={status?.runtime.osDescription || "-"} /> <DetailRow label={t("adminSystemOs")} value={status?.runtime.osDescription || "-"} />
<DetailRow label="Architecture" value={status?.runtime.processArchitecture || "-"} /> <DetailRow label={t("adminSystemArchitecture")} value={status?.runtime.processArchitecture || "-"} />
<DetailRow label="Machine" value={status?.runtime.machineName || "-"} /> <DetailRow label={t("adminSystemMachine")} value={status?.runtime.machineName || "-"} />
<DetailRow label="Content root" value={status?.contentRoot || "-"} /> <DetailRow label={t("adminSystemContentRoot")} value={status?.contentRoot || "-"} />
<DetailRow label="Build stamp" value={displayMetadata(status?.buildStamp)} /> <DetailRow label={t("adminSystemBuildStamp")} value={displayMetadata(status?.buildStamp)} />
<DetailRow label="Auth required" value={status?.auth.required ? "Yes" : "No"} /> <DetailRow label={t("adminSystemAuthRequired")} value={status?.auth.required ? "Yes" : "No"} />
<DetailRow label="JWT key configured" value={status?.auth.hasJwtKey ? "Yes" : "No"} /> <DetailRow label={t("adminSystemJwtConfigured")} value={status?.auth.hasJwtKey ? "Yes" : "No"} />
<DetailRow label="Google login configured" value={status?.auth.googleConfigured ? "Yes" : "No"} /> <DetailRow label={t("adminSystemGoogleConfigured")} value={status?.auth.googleConfigured ? "Yes" : "No"} />
<DetailRow label="Gmail integration configured" value={status?.auth.gmailConfigured ? "Yes" : "No"} /> <DetailRow label={t("adminSystemGmailConfigured")} value={status?.auth.gmailConfigured ? "Yes" : "No"} />
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>
@@ -283,27 +283,27 @@ export default function AdminSystemPage() {
<Paper sx={{ p: 2, borderRadius: 3 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemEmailConfig")}</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemEmailConfig")}</Typography>
<Stack spacing={0.75}> <Stack spacing={0.75}>
<DetailRow label="Enabled" value={status?.email.enabled ? "Yes" : "No"} /> <DetailRow label={t("adminSystemEnabled")} value={status?.email.enabled ? "Yes" : "No"} />
<DetailRow label="From" value={status?.email.from || "-"} /> <DetailRow label={t("adminSystemFrom")} value={status?.email.from || "-"} />
<DetailRow label="From name" value={status?.email.fromName || "-"} /> <DetailRow label={t("adminSystemFromName")} value={status?.email.fromName || "-"} />
<DetailRow label="Host" value={status?.email.host || "-"} /> <DetailRow label={t("adminSystemHost")} value={status?.email.host || "-"} />
<DetailRow label="Port" value={status?.email.port ?? "-"} /> <DetailRow label={t("adminSystemPort")} value={status?.email.port ?? "-"} />
<DetailRow label="SSL" value={status?.email.enableSsl ? "Yes" : "No"} /> <DetailRow label={t("adminSystemSsl")} value={status?.email.enableSsl ? "Yes" : "No"} />
</Stack> </Stack>
</Paper> </Paper>
<Paper sx={{ p: 2, borderRadius: 3 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerRuntime")}</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerRuntime")}</Typography>
<Stack spacing={0.75}> <Stack spacing={0.75}>
<DetailRow label="Model" value={status?.ai.model || "-"} /> <DetailRow label={t("adminSystemModel")} value={status?.ai.model || "-"} />
<DetailRow label="Device" value={status?.ai.device || "-"} /> <DetailRow label={t("adminSystemDevice")} value={status?.ai.device || "-"} />
<DetailRow label="GPU available" value={status?.ai.gpuAvailable ? "Yes" : "No"} /> <DetailRow label={t("adminSystemGpuAvailable")} value={status?.ai.gpuAvailable ? "Yes" : "No"} />
<DetailRow label="GPU name" value={status?.ai.gpuName || "-"} /> <DetailRow label={t("adminSystemGpuName")} value={status?.ai.gpuName || "-"} />
<DetailRow label="Health latency" value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} /> <DetailRow label={t("adminSystemHealthLatency")} value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} />
<DetailRow label="Probe latency" value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} /> <DetailRow label={t("adminSystemProbeLatency")} value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} />
<DetailRow label="Last probe" value={formatDate(status?.ai.lastProbeAt)} /> <DetailRow label={t("adminSystemLastProbe")} value={formatDate(status?.ai.lastProbeAt)} />
<DetailRow label="Last successful probe" value={formatDate(status?.ai.lastProbeSuccessAt)} /> <DetailRow label={t("adminSystemLastSuccessfulProbe")} value={formatDate(status?.ai.lastProbeSuccessAt)} />
<DetailRow label="Last summarization success" value={formatDate(status?.ai.lastSuccessAt)} /> <DetailRow label={t("adminSystemLastSummarizationSuccess")} value={formatDate(status?.ai.lastSuccessAt)} />
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>
@@ -328,14 +328,14 @@ export default function AdminSystemPage() {
<Paper sx={{ p: 2, borderRadius: 3 }}> <Paper sx={{ p: 2, borderRadius: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerTelemetry")}</Typography> <Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminSystemSummarizerTelemetry")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(8, 1fr)" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(8, 1fr)" }, gap: 2 }}>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Requests</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.requests ?? 0}</Typography></Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemRequests")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.requests ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache hits</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.cacheHits ?? 0}</Typography></Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemCacheHits")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.cacheHits ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Cache misses</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.cacheMisses ?? 0}</Typography></Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemCacheMisses")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.cacheMisses ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Failures</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.failures ?? 0}</Typography></Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemFailures")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.failures ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Probe failures</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.probeFailures ?? 0}</Typography></Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemProbeFailures")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.probeFailures ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>Avg latency</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.averageLatencyMs != null ? `${status.ai.averageLatencyMs} ms` : "-"}</Typography></Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemAvgLatency")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.averageLatencyMs != null ? `${status.ai.averageLatencyMs} ms` : "-"}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>OCR requests</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.ocrRequests ?? 0}</Typography></Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemOcrRequests")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.ocrRequests ?? 0}</Typography></Box>
<Box><Typography variant="overline" sx={{ color: "text.secondary" }}>OCR avg latency</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.averageOcrLatencyMs != null ? `${status.ai.averageOcrLatencyMs} ms` : "-"}</Typography></Box> <Box><Typography variant="overline" sx={{ color: "text.secondary" }}>{t("adminSystemOcrAvgLatency")}</Typography><Typography variant="h6" sx={{ fontWeight: 900 }}>{status?.ai.averageOcrLatencyMs != null ? `${status.ai.averageOcrLatencyMs} ms` : "-"}</Typography></Box>
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 2 }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 2 }}>
<Chip label={status?.database.canConnect ? t("adminSystemDatabaseConnected") : t("adminSystemDatabaseIssue")} color={status?.database.canConnect ? "success" : "error"} size="small" /> <Chip label={status?.database.canConnect ? t("adminSystemDatabaseConnected") : t("adminSystemDatabaseIssue")} color={status?.database.canConnect ? "success" : "error"} size="small" />
@@ -343,7 +343,7 @@ export default function AdminSystemPage() {
<Chip label={status?.auth.googleConfigured ? t("adminSystemGoogleReady") : t("adminSystemGoogleOff")} variant="outlined" size="small" /> <Chip label={status?.auth.googleConfigured ? t("adminSystemGoogleReady") : t("adminSystemGoogleOff")} variant="outlined" size="small" />
<Chip label={status?.auth.gmailConfigured ? t("adminSystemGmailReady") : t("adminSystemGmailIncomplete")} variant="outlined" size="small" /> <Chip label={status?.auth.gmailConfigured ? t("adminSystemGmailReady") : t("adminSystemGmailIncomplete")} variant="outlined" size="small" />
<Chip label={status?.ai.gpuAvailable ? t("adminSystemGpuVisible") : t("adminSystemCpuMode")} color={status?.ai.gpuAvailable ? "success" : "default"} size="small" /> <Chip label={status?.ai.gpuAvailable ? t("adminSystemGpuVisible") : t("adminSystemCpuMode")} color={status?.ai.gpuAvailable ? "success" : "default"} size="small" />
<Chip label={status?.ai.ocrAvailable ? `OCR ${status.ai.ocrLanguages || "enabled"}` : "OCR unavailable"} variant="outlined" size="small" /> <Chip label={status?.ai.ocrAvailable ? `OCR ${status.ai.ocrLanguages || "enabled"}` : t("adminSystemOcrUnavailable")} variant="outlined" size="small" />
</Box> </Box>
</Paper> </Paper>
</Box> </Box>