Localize kanban, auth, backup, and admin utilities
This commit is contained in:
@@ -313,7 +313,7 @@ export default function Attachments({ jobId }: { jobId: number }) {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
{preview ? <Button onClick={() => void download({ id: 0, fileName: preview.name, fileType: preview.type, fileSize: 0, uploadDate: "" } as AttachmentItem)}>Download</Button> : null}
|
{preview ? <Button onClick={() => void download({ id: 0, fileName: preview.name, fileType: preview.type, fileSize: 0, uploadDate: "" } as AttachmentItem)}>{t("attachmentsDownload")}</Button> : null}
|
||||||
<Button onClick={() => setPreviewOpen(false)}>{t("close")}</Button>
|
<Button onClick={() => setPreviewOpen(false)}>{t("close")}</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Box, Button, Paper, Typography } from "@mui/material";
|
|||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { clearAuthToken, getAuthToken } from "../auth";
|
import { clearAuthToken, getAuthToken } from "../auth";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
type MeResponse = {
|
type MeResponse = {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -23,6 +24,7 @@ type MeResponse = {
|
|||||||
|
|
||||||
export default function AuthStatusCard() {
|
export default function AuthStatusCard() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useI18n();
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
const [me, setMe] = useState<MeResponse | null>(null);
|
const [me, setMe] = useState<MeResponse | null>(null);
|
||||||
|
|
||||||
@@ -42,11 +44,11 @@ export default function AuthStatusCard() {
|
|||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 2, p: 2 }}>
|
<Paper sx={{ mt: 2, p: 2 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
Authentication
|
{t("authStatusTitle")}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{!token ? (
|
{!token ? (
|
||||||
<Typography sx={{ color: "text.secondary" }}>Not signed in.</Typography>
|
<Typography sx={{ color: "text.secondary" }}>{t("authStatusNotSignedIn")}</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
@@ -54,12 +56,12 @@ export default function AuthStatusCard() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
{me?.roles && me.roles.length > 0 ? (
|
{me?.roles && me.roles.length > 0 ? (
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
Roles: {me.roles.join(", ")}
|
{t("authStatusRoles", { roles: me.roles.join(", ") })}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
{me?.googleLink?.linked ? (
|
{me?.googleLink?.linked ? (
|
||||||
<Typography sx={{ color: "text.secondary" }}>
|
<Typography sx={{ color: "text.secondary" }}>
|
||||||
Google linked{me.googleLink.email ? `: ${me.googleLink.email}` : "."}
|
{t("authStatusGoogleLinked", { suffix: me.googleLink.email ? `: ${me.googleLink.email}` : "." })}
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -69,10 +71,10 @@ export default function AuthStatusCard() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearAuthToken();
|
clearAuthToken();
|
||||||
setMe(null);
|
setMe(null);
|
||||||
toast("Signed out.", "info");
|
toast(t("signedOut"), "info");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign out
|
{t("signOut")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import React, { useState } from "react";
|
|||||||
import { Box, Button, Paper, Typography } from "@mui/material";
|
import { Box, Button, Paper, Typography } from "@mui/material";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
export default function BackupCard() {
|
export default function BackupCard() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useI18n();
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
const downloadEncrypted = async () => {
|
const downloadEncrypted = async () => {
|
||||||
@@ -25,9 +27,9 @@ export default function BackupCard() {
|
|||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
window.setTimeout(() => URL.revokeObjectURL(url), 5000);
|
window.setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
toast("Backup downloaded.", "success");
|
toast(t("backupDownloaded"), "success");
|
||||||
} catch {
|
} catch {
|
||||||
toast("Backup failed.", "error");
|
toast(t("backupFailed"), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setDownloading(false);
|
setDownloading(false);
|
||||||
}
|
}
|
||||||
@@ -36,14 +38,14 @@ export default function BackupCard() {
|
|||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 2, p: 2 }}>
|
<Paper sx={{ mt: 2, p: 2 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
Data Safety
|
{t("backupTitle")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ color: "text.secondary", mb: 1 }}>
|
<Typography sx={{ color: "text.secondary", mb: 1 }}>
|
||||||
One-click encrypted backup (Windows DPAPI).
|
{t("backupBody")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
<Button variant="contained" onClick={downloadEncrypted} disabled={downloading}>
|
<Button variant="contained" onClick={downloadEncrypted} disabled={downloading}>
|
||||||
{downloading ? "Preparing..." : "Download Encrypted Backup"}
|
{downloading ? t("backupPreparing") : t("backupDownload")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
await load();
|
await load();
|
||||||
toast(t("correspondenceLogEmail"), "success");
|
toast(t("correspondenceLogEmail"), "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, "Failed to import email."), "error");
|
toast(getApiErrorMessage(error, t("addJobModalImportFailed")), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,9 +241,9 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get<{ url: string }>("/gmail/connect-url");
|
const res = await api.get<{ url: string }>("/gmail/connect-url");
|
||||||
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
|
const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes");
|
||||||
if (!popup) toast("Your browser blocked the Gmail popup.", "error");
|
if (!popup) toast(t("correspondenceBlockedPopup"), "error");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, "Failed to start Gmail connection."), "error");
|
toast(getApiErrorMessage(error, t("correspondenceStartGmailFailed")), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -254,18 +254,18 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
setGmailMessages([]);
|
setGmailMessages([]);
|
||||||
toast(t("googleUnlinked"), "success");
|
toast(t("googleUnlinked"), "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, "Failed to disconnect Gmail."), "error");
|
toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteMessage = async (messageId: number) => {
|
const deleteMessage = async (messageId: number) => {
|
||||||
if (!(await confirmAction("Remove this correspondence message?", { title: "Delete message", confirmLabel: t("jobTableDeleteSelected"), destructive: true }))) return;
|
if (!(await confirmAction(t("correspondenceDeleteConfirm"), { title: t("correspondenceDeleteTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/correspondence/${messageId}`);
|
await api.delete(`/correspondence/${messageId}`);
|
||||||
await load();
|
await load();
|
||||||
toast("Message removed.", "success");
|
toast(t("correspondenceDeleted"), "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, "Failed to remove message."), "error");
|
toast(getApiErrorMessage(error, t("correspondenceDeleteFailed")), "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
await load();
|
await load();
|
||||||
toast(t("correspondenceImportEmail"), "success");
|
toast(t("correspondenceImportEmail"), "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(getApiErrorMessage(error, "Failed to import Gmail message."), "error");
|
toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setImportingMessageId(null);
|
setImportingMessageId(null);
|
||||||
}
|
}
|
||||||
@@ -287,9 +287,9 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
setImportingThreadId(threadId);
|
setImportingThreadId(threadId);
|
||||||
const res = await api.post<{ imported: number; skipped: number; threadId?: string }>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds });
|
const res = await api.post<{ imported: number; skipped: number; threadId?: string }>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds });
|
||||||
await load();
|
await load();
|
||||||
toast(`Imported ${res.data.imported} messages${res.data.skipped ? `, skipped ${res.data.skipped} duplicates` : ""}.`, "success");
|
toast(t("correspondenceImportThreadResult", { imported: res.data.imported, skippedText: res.data.skipped ? t("correspondenceImportThreadSkipped", { count: res.data.skipped }) : "" }), "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(getApiErrorMessage(error, "Failed to import Gmail thread."), "error");
|
toast(getApiErrorMessage(error, t("correspondenceImportThreadFailed")), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setImportingThreadId(null);
|
setImportingThreadId(null);
|
||||||
}
|
}
|
||||||
@@ -299,7 +299,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
<Box>
|
<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)" }}>
|
<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 ? (
|
{messages.length === 0 ? (
|
||||||
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>No messages yet.</Typography>
|
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("correspondenceNoMessages")}</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
{messages.map((m) => {
|
{messages.map((m) => {
|
||||||
@@ -384,12 +384,12 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
<TextField label={t("correspondenceSearchGmail")} value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} 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}>{t("correspondenceSearch")}</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={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
|
||||||
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
|
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
|
||||||
{gmailMessagesLoading ? (
|
{gmailMessagesLoading ? (
|
||||||
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
||||||
) : groupedByThread.length === 0 ? (
|
) : groupedByThread.length === 0 ? (
|
||||||
<Typography sx={{ color: "text.secondary", p: 2 }}>No Gmail messages found.</Typography>
|
<Typography sx={{ color: "text.secondary", p: 2 }}>{t("correspondenceNoGmailMessages")}</Typography>
|
||||||
) : (
|
) : (
|
||||||
<List disablePadding>
|
<List disablePadding>
|
||||||
{groupedByThread.map(({ threadId, items }, threadIndex) => (
|
{groupedByThread.map(({ threadId, items }, threadIndex) => (
|
||||||
@@ -398,11 +398,11 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
<Box sx={{ p: 1.5, backgroundColor: alpha(theme.palette.primary.main, 0.04) }}>
|
<Box sx={{ p: 1.5, backgroundColor: alpha(theme.palette.primary.main, 0.04) }}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontWeight: 800 }}>{items[0]?.subject || "(No subject)"}</Typography>
|
<Typography sx={{ fontWeight: 800 }}>{items[0]?.subject || t("correspondenceNoSubject")}</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{items.length} message{items.length === 1 ? "" : "s"} in thread</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("correspondenceMessagesInThread", { count: items.length })}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === threadId} onClick={() => void importGmailThread(threadId, items.map((x) => x.id))}>
|
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === threadId} onClick={() => void importGmailThread(threadId, items.map((x) => x.id))}>
|
||||||
{importingThreadId === threadId ? "Importing..." : "Import thread"}
|
{importingThreadId === threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
{items.map((message, index) => (
|
{items.map((message, index) => (
|
||||||
@@ -410,11 +410,11 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
{index > 0 ? <Divider sx={{ my: 1 }} /> : null}
|
{index > 0 ? <Divider sx={{ my: 1 }} /> : null}
|
||||||
<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 || t("correspondenceNoSubject")}</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 || t("correspondenceUnknown")}</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" }}>{t("correspondenceFromLabel", { value: 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 ? t("correspondenceImporting") : t("correspondenceImportEmail")}
|
||||||
</Button>
|
</Button>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ErrorBoundaryInnerProps = Props & {
|
||||||
|
t: (key: "errorBoundaryTitle" | "errorBoundaryBody" | "errorBoundaryUnknown" | "errorBoundaryRefresh") => string;
|
||||||
|
};
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
errorId?: string;
|
errorId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class ErrorBoundary extends React.Component<Props, State> {
|
class ErrorBoundaryInner extends React.Component<ErrorBoundaryInnerProps, State> {
|
||||||
state: State = { hasError: false };
|
state: State = { hasError: false };
|
||||||
|
|
||||||
static getDerivedStateFromError(_: any) {
|
static getDerivedStateFromError(_: any) {
|
||||||
@@ -21,7 +27,6 @@ export default class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
const errorId = `ui_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
const errorId = `ui_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
this.setState({ errorId });
|
this.setState({ errorId });
|
||||||
|
|
||||||
// Best-effort: report to API. If offline/misconfigured, we still show fallback UI.
|
|
||||||
void api.post("/client-errors", {
|
void api.post("/client-errors", {
|
||||||
errorId,
|
errorId,
|
||||||
message: String(error?.message ?? error),
|
message: String(error?.message ?? error),
|
||||||
@@ -38,16 +43,20 @@ export default class ErrorBoundary extends React.Component<Props, State> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24, maxWidth: 820, margin: "0 auto" }}>
|
<div style={{ padding: 24, maxWidth: 820, margin: "0 auto" }}>
|
||||||
<h2 style={{ margin: "0 0 8px 0" }}>Something crashed.</h2>
|
<h2 style={{ margin: "0 0 8px 0" }}>{this.props.t("errorBoundaryTitle")}</h2>
|
||||||
<div style={{ opacity: 0.8, marginBottom: 16 }}>
|
<div style={{ opacity: 0.8, marginBottom: 16 }}>
|
||||||
Try refreshing. If it keeps happening, capture this ID:
|
{this.props.t("errorBoundaryBody")}
|
||||||
<div style={{ fontFamily: "monospace", marginTop: 6 }}>
|
<div style={{ fontFamily: "monospace", marginTop: 6 }}>
|
||||||
{this.state.errorId ?? "unknown"}
|
{this.state.errorId ?? this.props.t("errorBoundaryUnknown")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => window.location.reload()}>Refresh</button>
|
<button onClick={() => window.location.reload()}>{this.props.t("errorBoundaryRefresh")}</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ErrorBoundary(props: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return <ErrorBoundaryInner {...props} t={t} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ function toneColor(theme: any, status: Status | "Other"): string {
|
|||||||
return theme.palette.primary.main;
|
return theme.palette.primary.main;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusLabel(t: (key: any, params?: any) => string, status: Status): string {
|
||||||
|
switch (status) {
|
||||||
|
case "Applied":
|
||||||
|
return t("statusApplied");
|
||||||
|
case "Waiting":
|
||||||
|
return t("statusWaiting");
|
||||||
|
case "Interview":
|
||||||
|
return t("statusInterview");
|
||||||
|
case "Offer":
|
||||||
|
return t("statusOffer");
|
||||||
|
case "Rejected":
|
||||||
|
return t("statusRejected");
|
||||||
|
case "Ghosted":
|
||||||
|
return t("statusGhosted");
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function KanbanBoard() {
|
export default function KanbanBoard() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -79,7 +98,7 @@ export default function KanbanBoard() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>
|
||||||
Drag cards between columns to update status.
|
{t("kanbanHint")}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
|
||||||
@@ -101,7 +120,7 @@ export default function KanbanBoard() {
|
|||||||
>
|
>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: theme.palette.mode === "dark" ? "#f8fafc" : "inherit" }}>
|
||||||
{status}
|
{statusLabel(t, status)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
@@ -160,7 +179,7 @@ export default function KanbanBoard() {
|
|||||||
))}
|
))}
|
||||||
{list.length === 0 && (
|
{list.length === 0 && (
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
|
<Typography variant="body2" sx={{ color: "text.secondary", py: 1 }}>
|
||||||
Drop here
|
{t("kanbanDropHere")}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -174,7 +193,7 @@ export default function KanbanBoard() {
|
|||||||
.filter((s) => s !== currentMenuStatus)
|
.filter((s) => s !== currentMenuStatus)
|
||||||
.map((s) => (
|
.map((s) => (
|
||||||
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
|
<MenuItem key={s} onClick={() => { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}>
|
||||||
{t("jobTableSetStatus", { status: s })}
|
{t("jobTableSetStatus", { status: statusLabel(t, s) })}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default function RulesSettingsCard() {
|
|||||||
await api.put("/rules", s);
|
await api.put("/rules", s);
|
||||||
toast(t("rulesSave"), "success");
|
toast(t("rulesSave"), "success");
|
||||||
} catch {
|
} catch {
|
||||||
toast("Failed to save rules.", "error");
|
toast(t("rulesSaveFailed"), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from "../api";
|
|||||||
import { getAuthToken } from "../auth";
|
import { getAuthToken } from "../auth";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useDialogActions } from "../dialogs";
|
import { useDialogActions } from "../dialogs";
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
type UserDto = {
|
type UserDto = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +19,7 @@ type UserDto = {
|
|||||||
export default function UserManagementCard() {
|
export default function UserManagementCard() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { confirmAction } = useDialogActions();
|
const { confirmAction } = useDialogActions();
|
||||||
|
const { t } = useI18n();
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
|
|
||||||
const [supported, setSupported] = useState<boolean | null>(null);
|
const [supported, setSupported] = useState<boolean | null>(null);
|
||||||
@@ -41,7 +43,7 @@ export default function UserManagementCard() {
|
|||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
setSupported(false);
|
setSupported(false);
|
||||||
} else {
|
} else {
|
||||||
toast("Failed to load users.", "error");
|
toast(t("adminUsersLoadFailed"), "error");
|
||||||
setSupported(false);
|
setSupported(false);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -65,21 +67,21 @@ export default function UserManagementCard() {
|
|||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 2, p: 2 }}>
|
<Paper sx={{ mt: 2, p: 2 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||||
User management
|
{t("adminUsersTitle")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
||||||
Admin-only.
|
{t("adminUsersSubtitle")}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.5 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.5 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Email"
|
label={t("profileEmail")}
|
||||||
value={newEmail}
|
value={newEmail}
|
||||||
onChange={(e) => setNewEmail(e.target.value)}
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Password"
|
label={t("adminUsersPassword")}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
type="password"
|
type="password"
|
||||||
@@ -92,7 +94,7 @@ export default function UserManagementCard() {
|
|||||||
variant={newIsAdmin ? "contained" : "outlined"}
|
variant={newIsAdmin ? "contained" : "outlined"}
|
||||||
onClick={() => setNewIsAdmin((v) => !v)}
|
onClick={() => setNewIsAdmin((v) => !v)}
|
||||||
>
|
>
|
||||||
{newIsAdmin ? "Admin: Yes" : "Admin: No"}
|
{newIsAdmin ? t("adminUsersAdminYes") : t("adminUsersAdminNo")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
@@ -107,21 +109,21 @@ export default function UserManagementCard() {
|
|||||||
setNewEmail("");
|
setNewEmail("");
|
||||||
setNewPassword("");
|
setNewPassword("");
|
||||||
setNewIsAdmin(false);
|
setNewIsAdmin(false);
|
||||||
toast("User created.", "success");
|
toast(t("adminUsersCreated"), "success");
|
||||||
await load();
|
await load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.response?.data || e?.message || "Failed to create user.";
|
const msg = e?.response?.data || e?.message || t("adminUsersCreateFailed");
|
||||||
toast(String(msg), "error");
|
toast(String(msg), "error");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create user
|
{t("adminUsersCreateUser")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 2, display: "flex", flexDirection: "column", gap: 1 }}>
|
<Box sx={{ mt: 2, display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<Typography sx={{ color: "text.secondary" }}>No users.</Typography>
|
<Typography sx={{ color: "text.secondary" }}>{t("adminUsersNoUsers")}</Typography>
|
||||||
) : (
|
) : (
|
||||||
users.map((u) => (
|
users.map((u) => (
|
||||||
<Box
|
<Box
|
||||||
@@ -140,10 +142,10 @@ export default function UserManagementCard() {
|
|||||||
>
|
>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontWeight: 900, lineHeight: 1.2 }}>
|
<Typography sx={{ fontWeight: 900, lineHeight: 1.2 }}>
|
||||||
{u.email || u.userName || u.id}
|
{u.userName || u.email || u.id}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
Roles: {u.roles?.length ? u.roles.join(", ") : "—"}
|
{t("adminUsersRolesLabel")}: {u.roles?.length ? u.roles.join(", ") : "—"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -151,17 +153,17 @@ export default function UserManagementCard() {
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="error"
|
color="error"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!(await confirmAction("Delete this user?", { title: "Delete user", confirmLabel: "Delete", destructive: true }))) return;
|
if (!(await confirmAction(t("adminUsersDeleteConfirmBody"), { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/users/${u.id}`);
|
await api.delete(`/users/${u.id}`);
|
||||||
toast("User deleted.", "info");
|
toast(t("adminUsersDeleted"), "info");
|
||||||
await load();
|
await load();
|
||||||
} catch {
|
} catch {
|
||||||
toast("Failed to delete user.", "error");
|
toast(t("adminUsersDeleteFailed"), "error");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
{t("adminUsersDelete")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -169,14 +171,14 @@ export default function UserManagementCard() {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await api.post(`/users/${u.id}/send-password-reset`);
|
await api.post(`/users/${u.id}/send-password-reset`);
|
||||||
toast("Password reset email sent.", "success");
|
toast(t("adminUsersResetSent"), "success");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.response?.data || e?.message || "Failed to send reset email.";
|
const msg = e?.response?.data || e?.message || t("adminUsersResetFailed");
|
||||||
toast(String(msg), "error");
|
toast(String(msg), "error");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Send reset
|
{t("adminUsersSendReset")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export const translations = {
|
|||||||
save: "Save",
|
save: "Save",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
createJob: "Create job",
|
createJob: "Create job",
|
||||||
|
yes: "Yes",
|
||||||
|
noWord: "No",
|
||||||
createAndAddAnother: "Create & add another",
|
createAndAddAnother: "Create & add another",
|
||||||
loading: "Loading...",
|
loading: "Loading...",
|
||||||
notFoundTitle: "Page not found",
|
notFoundTitle: "Page not found",
|
||||||
@@ -259,6 +261,7 @@ export const translations = {
|
|||||||
adminUsersAdmin: "Admin",
|
adminUsersAdmin: "Admin",
|
||||||
adminUsersSendReset: "Send reset",
|
adminUsersSendReset: "Send reset",
|
||||||
adminUsersDelete: "Delete",
|
adminUsersDelete: "Delete",
|
||||||
|
adminUsersRolesLabel: "Roles",
|
||||||
adminUsersConfirmed: "Confirmed",
|
adminUsersConfirmed: "Confirmed",
|
||||||
adminUsersActions: "Actions",
|
adminUsersActions: "Actions",
|
||||||
adminUsersNoUsers: "No users.",
|
adminUsersNoUsers: "No users.",
|
||||||
@@ -271,6 +274,13 @@ export const translations = {
|
|||||||
adminUsersDeleteFailed: "Failed to delete user.",
|
adminUsersDeleteFailed: "Failed to delete user.",
|
||||||
adminUsersCreated: "User created.",
|
adminUsersCreated: "User created.",
|
||||||
adminUsersCreateFailed: "Failed to create user.",
|
adminUsersCreateFailed: "Failed to create user.",
|
||||||
|
adminUsersLoadFailed: "Failed to load users.",
|
||||||
|
adminUsersAdminYes: "Admin: Yes",
|
||||||
|
adminUsersAdminNo: "Admin: No",
|
||||||
|
adminUsersDeleteConfirmBody: "Delete this user?",
|
||||||
|
adminUsersPassword: "Password",
|
||||||
|
kanbanHint: "Drag cards between columns to update status.",
|
||||||
|
kanbanDropHere: "Drop here",
|
||||||
adminSystemTitle: "System status",
|
adminSystemTitle: "System status",
|
||||||
adminSystemSubtitle: "Production diagnostics for runtime, database, auth, email, AI service health, and OCR readiness.",
|
adminSystemSubtitle: "Production diagnostics for runtime, database, auth, email, AI service health, and OCR readiness.",
|
||||||
adminSystemRunProbe: "Run probe now",
|
adminSystemRunProbe: "Run probe now",
|
||||||
@@ -352,6 +362,17 @@ export const translations = {
|
|||||||
correspondenceImportThread: "Import thread",
|
correspondenceImportThread: "Import thread",
|
||||||
correspondenceImporting: "Importing...",
|
correspondenceImporting: "Importing...",
|
||||||
correspondenceFromLabel: "From: {value}",
|
correspondenceFromLabel: "From: {value}",
|
||||||
|
correspondenceBlockedPopup: "Your browser blocked the Gmail popup.",
|
||||||
|
correspondenceStartGmailFailed: "Failed to start Gmail connection.",
|
||||||
|
correspondenceDisconnectFailed: "Failed to disconnect Gmail.",
|
||||||
|
correspondenceDeleteConfirm: "Remove this correspondence message?",
|
||||||
|
correspondenceDeleteTitle: "Delete message",
|
||||||
|
correspondenceDeleted: "Message removed.",
|
||||||
|
correspondenceDeleteFailed: "Failed to remove message.",
|
||||||
|
correspondenceImportGmailFailed: "Failed to import Gmail message.",
|
||||||
|
correspondenceImportThreadResult: "Imported {imported} messages{skippedText}.",
|
||||||
|
correspondenceImportThreadSkipped: ", skipped {count} duplicates",
|
||||||
|
correspondenceImportThreadFailed: "Failed to import Gmail thread.",
|
||||||
attachmentsTitle: "Attachments ({count})",
|
attachmentsTitle: "Attachments ({count})",
|
||||||
attachmentsSubtitle: "Upload resumes, cover letters, portfolios, and supporting files for this application.",
|
attachmentsSubtitle: "Upload resumes, cover letters, portfolios, and supporting files for this application.",
|
||||||
attachmentsImages: "{count} images",
|
attachmentsImages: "{count} images",
|
||||||
@@ -446,6 +467,20 @@ export const translations = {
|
|||||||
signedOut: "Signed out.",
|
signedOut: "Signed out.",
|
||||||
signedInAs: "Signed in as {name}.",
|
signedInAs: "Signed in as {name}.",
|
||||||
unlinkGoogle: "Unlink Google",
|
unlinkGoogle: "Unlink Google",
|
||||||
|
backupTitle: "Data safety",
|
||||||
|
backupBody: "One-click encrypted backup (Windows DPAPI).",
|
||||||
|
backupPreparing: "Preparing...",
|
||||||
|
backupDownload: "Download encrypted backup",
|
||||||
|
backupDownloaded: "Backup downloaded.",
|
||||||
|
backupFailed: "Backup failed.",
|
||||||
|
authStatusTitle: "Authentication",
|
||||||
|
authStatusNotSignedIn: "Not signed in.",
|
||||||
|
authStatusRoles: "Roles: {roles}",
|
||||||
|
authStatusGoogleLinked: "Google linked{suffix}",
|
||||||
|
errorBoundaryTitle: "Something crashed.",
|
||||||
|
errorBoundaryBody: "Try refreshing. If it keeps happening, capture this ID:",
|
||||||
|
errorBoundaryUnknown: "unknown",
|
||||||
|
errorBoundaryRefresh: "Refresh",
|
||||||
importExportTitle: "Import / Export",
|
importExportTitle: "Import / Export",
|
||||||
importExportBody: "Import expects the JSON exported by this app (an array of job objects with embedded company).",
|
importExportBody: "Import expects the JSON exported by this app (an array of job objects with embedded company).",
|
||||||
exportJson: "Export JSON",
|
exportJson: "Export JSON",
|
||||||
@@ -676,6 +711,7 @@ export const translations = {
|
|||||||
rulesFeedbackGhostDays: "Feedback: ghost days",
|
rulesFeedbackGhostDays: "Feedback: ghost days",
|
||||||
rulesSaving: "Saving...",
|
rulesSaving: "Saving...",
|
||||||
rulesSave: "Save Rules",
|
rulesSave: "Save Rules",
|
||||||
|
rulesSaveFailed: "Failed to save rules.",
|
||||||
},
|
},
|
||||||
no: {
|
no: {
|
||||||
appTitle: "Jobbjakt",
|
appTitle: "Jobbjakt",
|
||||||
@@ -730,6 +766,8 @@ export const translations = {
|
|||||||
save: "Lagre",
|
save: "Lagre",
|
||||||
create: "Opprett",
|
create: "Opprett",
|
||||||
createJob: "Opprett jobb",
|
createJob: "Opprett jobb",
|
||||||
|
yes: "Ja",
|
||||||
|
noWord: "Nei",
|
||||||
createAndAddAnother: "Opprett og legg til en til",
|
createAndAddAnother: "Opprett og legg til en til",
|
||||||
loading: "Laster...",
|
loading: "Laster...",
|
||||||
notFoundTitle: "Siden ble ikke funnet",
|
notFoundTitle: "Siden ble ikke funnet",
|
||||||
@@ -935,6 +973,7 @@ export const translations = {
|
|||||||
adminUsersAdmin: "Admin",
|
adminUsersAdmin: "Admin",
|
||||||
adminUsersSendReset: "Send tilbakestilling",
|
adminUsersSendReset: "Send tilbakestilling",
|
||||||
adminUsersDelete: "Slett",
|
adminUsersDelete: "Slett",
|
||||||
|
adminUsersRolesLabel: "Roller",
|
||||||
adminUsersConfirmed: "Bekreftet",
|
adminUsersConfirmed: "Bekreftet",
|
||||||
adminUsersActions: "Handlinger",
|
adminUsersActions: "Handlinger",
|
||||||
adminUsersNoUsers: "Ingen brukere.",
|
adminUsersNoUsers: "Ingen brukere.",
|
||||||
@@ -947,6 +986,13 @@ export const translations = {
|
|||||||
adminUsersDeleteFailed: "Kunne ikke slette bruker.",
|
adminUsersDeleteFailed: "Kunne ikke slette bruker.",
|
||||||
adminUsersCreated: "Bruker opprettet.",
|
adminUsersCreated: "Bruker opprettet.",
|
||||||
adminUsersCreateFailed: "Kunne ikke opprette bruker.",
|
adminUsersCreateFailed: "Kunne ikke opprette bruker.",
|
||||||
|
adminUsersLoadFailed: "Kunne ikke laste brukere.",
|
||||||
|
adminUsersAdminYes: "Admin: Ja",
|
||||||
|
adminUsersAdminNo: "Admin: Nei",
|
||||||
|
adminUsersDeleteConfirmBody: "Slette denne brukeren?",
|
||||||
|
adminUsersPassword: "Passord",
|
||||||
|
kanbanHint: "Dra kort mellom kolonnene for å oppdatere status.",
|
||||||
|
kanbanDropHere: "Slipp her",
|
||||||
adminSystemTitle: "Systemstatus",
|
adminSystemTitle: "Systemstatus",
|
||||||
adminSystemSubtitle: "Produksjonsdiagnostikk for kjøretid, database, autentisering, e-post, AI-tjenestehelse og OCR-beredskap.",
|
adminSystemSubtitle: "Produksjonsdiagnostikk for kjøretid, database, autentisering, e-post, AI-tjenestehelse og OCR-beredskap.",
|
||||||
adminSystemRunProbe: "Kjør probe nå",
|
adminSystemRunProbe: "Kjør probe nå",
|
||||||
@@ -1028,6 +1074,17 @@ export const translations = {
|
|||||||
correspondenceImportThread: "Importer tråd",
|
correspondenceImportThread: "Importer tråd",
|
||||||
correspondenceImporting: "Importerer...",
|
correspondenceImporting: "Importerer...",
|
||||||
correspondenceFromLabel: "Fra: {value}",
|
correspondenceFromLabel: "Fra: {value}",
|
||||||
|
correspondenceBlockedPopup: "Nettleseren din blokkerte Gmail-popupen.",
|
||||||
|
correspondenceStartGmailFailed: "Kunne ikke starte Gmail-tilkobling.",
|
||||||
|
correspondenceDisconnectFailed: "Kunne ikke koble fra Gmail.",
|
||||||
|
correspondenceDeleteConfirm: "Fjerne denne meldingen fra korrespondansen?",
|
||||||
|
correspondenceDeleteTitle: "Slett melding",
|
||||||
|
correspondenceDeleted: "Melding fjernet.",
|
||||||
|
correspondenceDeleteFailed: "Kunne ikke fjerne melding.",
|
||||||
|
correspondenceImportGmailFailed: "Kunne ikke importere Gmail-melding.",
|
||||||
|
correspondenceImportThreadResult: "Importerte {imported} meldinger{skippedText}.",
|
||||||
|
correspondenceImportThreadSkipped: ", hoppet over {count} duplikater",
|
||||||
|
correspondenceImportThreadFailed: "Kunne ikke importere Gmail-tråd.",
|
||||||
attachmentsTitle: "Vedlegg ({count})",
|
attachmentsTitle: "Vedlegg ({count})",
|
||||||
attachmentsSubtitle: "Last opp CV-er, søknadsbrev, porteføljer og støttedokumenter for denne søknaden.",
|
attachmentsSubtitle: "Last opp CV-er, søknadsbrev, porteføljer og støttedokumenter for denne søknaden.",
|
||||||
attachmentsImages: "{count} bilder",
|
attachmentsImages: "{count} bilder",
|
||||||
@@ -1122,6 +1179,20 @@ export const translations = {
|
|||||||
signedOut: "Logget ut.",
|
signedOut: "Logget ut.",
|
||||||
signedInAs: "Logget inn som {name}.",
|
signedInAs: "Logget inn som {name}.",
|
||||||
unlinkGoogle: "Koble fra Google",
|
unlinkGoogle: "Koble fra Google",
|
||||||
|
backupTitle: "Datasikkerhet",
|
||||||
|
backupBody: "Kryptert sikkerhetskopi med ett klikk (Windows DPAPI).",
|
||||||
|
backupPreparing: "Forbereder...",
|
||||||
|
backupDownload: "Last ned kryptert sikkerhetskopi",
|
||||||
|
backupDownloaded: "Sikkerhetskopi lastet ned.",
|
||||||
|
backupFailed: "Sikkerhetskopiering mislyktes.",
|
||||||
|
authStatusTitle: "Autentisering",
|
||||||
|
authStatusNotSignedIn: "Ikke logget inn.",
|
||||||
|
authStatusRoles: "Roller: {roles}",
|
||||||
|
authStatusGoogleLinked: "Google koblet{suffix}",
|
||||||
|
errorBoundaryTitle: "Noe krasjet.",
|
||||||
|
errorBoundaryBody: "Prøv å laste inn siden på nytt. Hvis det fortsetter, ta vare på denne ID-en:",
|
||||||
|
errorBoundaryUnknown: "ukjent",
|
||||||
|
errorBoundaryRefresh: "Oppdater",
|
||||||
importExportTitle: "Import / eksport",
|
importExportTitle: "Import / eksport",
|
||||||
importExportBody: "Import forventer JSON eksportert av denne appen (en matrise med jobbobjekter med innebygd selskap).",
|
importExportBody: "Import forventer JSON eksportert av denne appen (en matrise med jobbobjekter med innebygd selskap).",
|
||||||
exportJson: "Eksporter JSON",
|
exportJson: "Eksporter JSON",
|
||||||
@@ -1352,6 +1423,7 @@ export const translations = {
|
|||||||
rulesFeedbackGhostDays: "Tilbakemelding: ghostingdager",
|
rulesFeedbackGhostDays: "Tilbakemelding: ghostingdager",
|
||||||
rulesSaving: "Lagrer...",
|
rulesSaving: "Lagrer...",
|
||||||
rulesSave: "Lagre regler",
|
rulesSave: "Lagre regler",
|
||||||
|
rulesSaveFailed: "Kunne ikke lagre regler.",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -249,14 +249,14 @@ export default function AdminSystemPage() {
|
|||||||
<Stack spacing={0.75}>
|
<Stack spacing={0.75}>
|
||||||
<DetailRow label={t("adminSystemProvider")} value={status?.database.provider || "-"} />
|
<DetailRow label={t("adminSystemProvider")} value={status?.database.provider || "-"} />
|
||||||
<DetailRow label={t("adminSystemTarget")} value={status?.database.target || "-"} />
|
<DetailRow label={t("adminSystemTarget")} value={status?.database.target || "-"} />
|
||||||
<DetailRow label={t("adminSystemConfigured")} value={status?.database.looksConfigured ? "Yes" : "No"} />
|
<DetailRow label={t("adminSystemConfigured")} value={status?.database.looksConfigured ? t("yes") : t("noWord")} />
|
||||||
<DetailRow label={t("adminSystemCanConnect")} value={status?.database.canConnect ? "Yes" : "No"} />
|
<DetailRow label={t("adminSystemCanConnect")} value={status?.database.canConnect ? t("yes") : t("noWord")} />
|
||||||
<DetailRow label={t("adminSystemUsesFileStorage")} value={status?.database.usesFileStorage ? "Yes" : "No"} />
|
<DetailRow label={t("adminSystemUsesFileStorage")} value={status?.database.usesFileStorage ? t("yes") : t("noWord")} />
|
||||||
<DetailRow label={t("adminSystemDataRoot")} value={status?.storage.dataRoot || "-"} />
|
<DetailRow label={t("adminSystemDataRoot")} value={status?.storage.dataRoot || "-"} />
|
||||||
<DetailRow label={t("adminSystemDbPath")} value={status?.storage.dbPath || "-"} />
|
<DetailRow label={t("adminSystemDbPath")} value={status?.storage.dbPath || "-"} />
|
||||||
<DetailRow label={t("adminSystemDbFileExists")} value={status?.storage.dbExists ? "Yes" : "No"} />
|
<DetailRow label={t("adminSystemDbFileExists")} value={status?.storage.dbExists ? t("yes") : t("noWord")} />
|
||||||
<DetailRow label={t("adminSystemDbSize")} value={formatBytes(status?.storage.dbSizeBytes)} />
|
<DetailRow label={t("adminSystemDbSize")} value={formatBytes(status?.storage.dbSizeBytes)} />
|
||||||
<DetailRow label="Companies" value={status?.storage.companyCount ?? 0} />
|
<DetailRow label={t("companies")} value={status?.storage.companyCount ?? 0} />
|
||||||
<DetailRow label={t("adminSystemJobs")} value={status?.storage.jobCount ?? 0} />
|
<DetailRow label={t("adminSystemJobs")} value={status?.storage.jobCount ?? 0} />
|
||||||
<DetailRow label={t("adminSystemDeletedJobs")} value={status?.storage.deletedCount ?? 0} />
|
<DetailRow label={t("adminSystemDeletedJobs")} value={status?.storage.deletedCount ?? 0} />
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -271,10 +271,10 @@ export default function AdminSystemPage() {
|
|||||||
<DetailRow label={t("adminSystemMachine")} value={status?.runtime.machineName || "-"} />
|
<DetailRow label={t("adminSystemMachine")} value={status?.runtime.machineName || "-"} />
|
||||||
<DetailRow label={t("adminSystemContentRoot")} value={status?.contentRoot || "-"} />
|
<DetailRow label={t("adminSystemContentRoot")} value={status?.contentRoot || "-"} />
|
||||||
<DetailRow label={t("adminSystemBuildStamp")} value={displayMetadata(status?.buildStamp)} />
|
<DetailRow label={t("adminSystemBuildStamp")} value={displayMetadata(status?.buildStamp)} />
|
||||||
<DetailRow label={t("adminSystemAuthRequired")} value={status?.auth.required ? "Yes" : "No"} />
|
<DetailRow label={t("adminSystemAuthRequired")} value={status?.auth.required ? t("yes") : t("noWord")} />
|
||||||
<DetailRow label={t("adminSystemJwtConfigured")} value={status?.auth.hasJwtKey ? "Yes" : "No"} />
|
<DetailRow label={t("adminSystemJwtConfigured")} value={status?.auth.hasJwtKey ? t("yes") : t("noWord")} />
|
||||||
<DetailRow label={t("adminSystemGoogleConfigured")} value={status?.auth.googleConfigured ? "Yes" : "No"} />
|
<DetailRow label={t("adminSystemGoogleConfigured")} value={status?.auth.googleConfigured ? t("yes") : t("noWord")} />
|
||||||
<DetailRow label={t("adminSystemGmailConfigured")} value={status?.auth.gmailConfigured ? "Yes" : "No"} />
|
<DetailRow label={t("adminSystemGmailConfigured")} value={status?.auth.gmailConfigured ? t("yes") : t("noWord")} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -283,12 +283,12 @@ 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={t("adminSystemEnabled")} value={status?.email.enabled ? "Yes" : "No"} />
|
<DetailRow label={t("adminSystemEnabled")} value={status?.email.enabled ? t("yes") : t("noWord")} />
|
||||||
<DetailRow label={t("adminSystemFrom")} value={status?.email.from || "-"} />
|
<DetailRow label={t("adminSystemFrom")} value={status?.email.from || "-"} />
|
||||||
<DetailRow label={t("adminSystemFromName")} value={status?.email.fromName || "-"} />
|
<DetailRow label={t("adminSystemFromName")} value={status?.email.fromName || "-"} />
|
||||||
<DetailRow label={t("adminSystemHost")} value={status?.email.host || "-"} />
|
<DetailRow label={t("adminSystemHost")} value={status?.email.host || "-"} />
|
||||||
<DetailRow label={t("adminSystemPort")} value={status?.email.port ?? "-"} />
|
<DetailRow label={t("adminSystemPort")} value={status?.email.port ?? "-"} />
|
||||||
<DetailRow label={t("adminSystemSsl")} value={status?.email.enableSsl ? "Yes" : "No"} />
|
<DetailRow label={t("adminSystemSsl")} value={status?.email.enableSsl ? t("yes") : t("noWord")} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ export default function AdminSystemPage() {
|
|||||||
<Stack spacing={0.75}>
|
<Stack spacing={0.75}>
|
||||||
<DetailRow label={t("adminSystemModel")} value={status?.ai.model || "-"} />
|
<DetailRow label={t("adminSystemModel")} value={status?.ai.model || "-"} />
|
||||||
<DetailRow label={t("adminSystemDevice")} value={status?.ai.device || "-"} />
|
<DetailRow label={t("adminSystemDevice")} value={status?.ai.device || "-"} />
|
||||||
<DetailRow label={t("adminSystemGpuAvailable")} value={status?.ai.gpuAvailable ? "Yes" : "No"} />
|
<DetailRow label={t("adminSystemGpuAvailable")} value={status?.ai.gpuAvailable ? t("yes") : t("noWord")} />
|
||||||
<DetailRow label={t("adminSystemGpuName")} value={status?.ai.gpuName || "-"} />
|
<DetailRow label={t("adminSystemGpuName")} value={status?.ai.gpuName || "-"} />
|
||||||
<DetailRow label={t("adminSystemHealthLatency")} value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} />
|
<DetailRow label={t("adminSystemHealthLatency")} value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} />
|
||||||
<DetailRow label={t("adminSystemProbeLatency")} value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} />
|
<DetailRow label={t("adminSystemProbeLatency")} value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} />
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default function AdminUsersPage() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>{t("profileEmail")}</TableCell>
|
<TableCell>{t("profileEmail")}</TableCell>
|
||||||
<TableCell>{t("profileUsername")}</TableCell>
|
<TableCell>{t("profileUsername")}</TableCell>
|
||||||
<TableCell>Roles</TableCell>
|
<TableCell>{t("adminUsersRolesLabel")}</TableCell>
|
||||||
<TableCell>{t("adminUsersConfirmed")}</TableCell>
|
<TableCell>{t("adminUsersConfirmed")}</TableCell>
|
||||||
<TableCell align="right">{t("adminUsersActions")}</TableCell>
|
<TableCell align="right">{t("adminUsersActions")}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -146,7 +146,7 @@ export default function AdminUsersPage() {
|
|||||||
<TableCell sx={{ fontWeight: 850 }}>{u.email || ""}</TableCell>
|
<TableCell sx={{ fontWeight: 850 }}>{u.email || ""}</TableCell>
|
||||||
<TableCell sx={{ color: "text.secondary" }}>{u.userName || ""}</TableCell>
|
<TableCell sx={{ color: "text.secondary" }}>{u.userName || ""}</TableCell>
|
||||||
<TableCell sx={{ color: "text.secondary" }}>{u.roles?.length ? u.roles.join(", ") : "-"}</TableCell>
|
<TableCell sx={{ color: "text.secondary" }}>{u.roles?.length ? u.roles.join(", ") : "-"}</TableCell>
|
||||||
<TableCell sx={{ color: "text.secondary" }}>{u.emailConfirmed ? "Yes" : "No"}</TableCell>
|
<TableCell sx={{ color: "text.secondary" }}>{u.emailConfirmed ? t("yes") : t("noWord")}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
||||||
<Button size="small" variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(u, !isAdmin)}>
|
<Button size="small" variant={isAdmin ? "contained" : "outlined"} onClick={() => void setAdminRole(u, !isAdmin)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user