import React, { useCallback, useEffect, useRef, useState } from "react"; import { Box, Alert, Button, Checkbox, FormControl, FormControlLabel, InputLabel, MenuItem, Select, Tab, Tabs, Typography, } from "@mui/material"; import { useNavigate } from "react-router-dom"; import { JobTableColumns } from "./JobTable"; import ImportExportJobs from "./ImportExportJobs"; import RulesSettingsCard, { RULE_FIELDS, RuleDraft, RuleField, RuleSettings } from "./RulesSettingsCard"; import BackupCard from "./BackupCard"; import QuickCaptureCard from "./QuickCaptureCard"; import AiUsageCard from "./AiUsageCard"; import AiPrivacySettingsCard from "./AiPrivacySettingsCard"; import { ThemeModePref } from "../themePrefs"; import { useI18n } from "../i18n/I18nProvider"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { PageSection } from "../layout/PageLayout"; import ViewStateNotice from "./ViewStateNotice"; interface Props { pageSize: 15 | 20 | 25; onPageSizeChange: (n: 15 | 20 | 25) => void; columns: JobTableColumns; onColumnsChange: (next: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; } function TabPanel({ value, index, label, children }: { value: number; index: number; label: string; children: React.ReactNode }) { if (value !== index) return null; return {children}; } type NotificationPrefs = { emailFollowUpRemindersEnabled: boolean; }; function ruleDraft(settings: RuleSettings): RuleDraft { return Object.fromEntries(RULE_FIELDS.map(field => [field, String(settings[field])])) as RuleDraft; } export default function SettingsView({ pageSize, onPageSizeChange, columns, onColumnsChange, themeMode, onThemeModeChange, }: Props) { const navigate = useNavigate(); const [tab, setTab] = useState(0); const { language, setLanguage, t } = useI18n(); const { toast } = useToast(); const [notificationPrefs, setNotificationPrefs] = useState(null); const [savedNotificationPrefs, setSavedNotificationPrefs] = useState(null); const [notificationError, setNotificationError] = useState<{ cause: unknown; action: boolean } | null>(null); const [loadingNotifications, setLoadingNotifications] = useState(true); const [savingNotifications, setSavingNotifications] = useState(false); const [savedRules, setSavedRules] = useState(null); const [rulesDraft, setRulesDraft] = useState(null); const [rulesLoadError, setRulesLoadError] = useState<{ cause: unknown } | null>(null); const [rulesSaveError, setRulesSaveError] = useState(false); const [invalidRules, setInvalidRules] = useState([]); const [savingRules, setSavingRules] = useState(false); const rulesSession = useRef({ mounted: true, loading: false, saving: false }); const notificationSession = useRef({ mounted: true, loading: false, saving: false, generation: 0 }); const notificationBody = useRef(null); const notificationRetryFocus = useRef(null); const currentTranslation = useRef(t); const rulesDirty = Boolean(rulesDraft && savedRules && RULE_FIELDS.some(field => rulesDraft[field] !== String(savedRules[field]))); const notificationsDirty = Boolean(notificationPrefs && savedNotificationPrefs && notificationPrefs.emailFollowUpRemindersEnabled !== savedNotificationPrefs.emailFollowUpRemindersEnabled); useEffect(() => { currentTranslation.current = t; }, [t]); useEffect(() => { const session = rulesSession.current; const notifications = notificationSession.current; session.mounted = true; notifications.mounted = true; return () => { session.mounted = false; notifications.mounted = false; notifications.generation += 1; notifications.loading = false; notifications.saving = false; }; }, []); const loadRules = async () => { const session = rulesSession.current; if (rulesDraft || session.loading) return; session.loading = true; setRulesLoadError(null); try { const { data } = await api.get("/rules"); if (!session.mounted) return; setSavedRules(data); setRulesDraft(ruleDraft(data)); } catch (error) { if (session.mounted) setRulesLoadError({ cause: error }); } finally { session.loading = false; } }; const selectSection = (next: number) => { setTab(next); if (next === 1) void loadRules(); }; const saveRules = async () => { const session = rulesSession.current; if (!rulesDraft || !savedRules || session.saving) return; const invalid = RULE_FIELDS.filter(field => !rulesDraft[field].trim() || !Number.isInteger(Number(rulesDraft[field])) || Number(rulesDraft[field]) < 1 || Number(rulesDraft[field]) > 365); setInvalidRules(invalid); if (invalid.length) return; const submittedDraft = rulesDraft; const submitted = { id: savedRules.id, ...Object.fromEntries(RULE_FIELDS.map(field => [field, Number(submittedDraft[field])])) } as RuleSettings; session.saving = true; setSavingRules(true); setRulesSaveError(false); try { await api.put("/rules", submitted); if (!session.mounted) return; setSavedRules(submitted); // A completed save acknowledges its snapshot, not edits made while it was pending. setRulesDraft(current => current && RULE_FIELDS.every(field => current[field] === submittedDraft[field]) ? ruleDraft(submitted) : current); toast(currentTranslation.current("rulesSaved"), "success"); } catch { if (session.mounted) setRulesSaveError(true); } finally { session.saving = false; if (session.mounted) setSavingRules(false); } }; const discardRules = () => { if (!savedRules || savingRules) return; setRulesDraft(ruleDraft(savedRules)); setInvalidRules([]); setRulesSaveError(false); }; const loadNotifications = useCallback(async () => { const session = notificationSession.current; if (!session.mounted || session.loading) return; const generation = session.generation; session.loading = true; setLoadingNotifications(true); setNotificationError(null); try { const { data } = await api.get("/notification-settings"); if (!session.mounted || generation !== session.generation) return; setNotificationPrefs(data); setSavedNotificationPrefs(data); } catch (cause) { if (session.mounted && generation === session.generation) setNotificationError({ cause, action: false }); } finally { if (generation === session.generation) { session.loading = false; if (session.mounted) setLoadingNotifications(false); } } }, []); useEffect(() => { void loadNotifications(); }, [loadNotifications]); const retryNotifications = () => { const active = document.activeElement; if (active instanceof HTMLElement && notificationBody.current?.contains(active)) notificationRetryFocus.current = active; void loadNotifications(); }; useEffect(() => { const target = notificationRetryFocus.current; if (loadingNotifications || !target) return; notificationRetryFocus.current = null; if (!target.isConnected && document.activeElement === document.body) notificationBody.current?.focus(); }, [loadingNotifications, notificationError, notificationPrefs]); const saveNotifications = async () => { const session = notificationSession.current; if (!session.mounted || !notificationPrefs || session.saving) return; const submitted = { emailFollowUpRemindersEnabled: notificationPrefs.emailFollowUpRemindersEnabled }; const generation = session.generation; session.saving = true; setSavingNotifications(true); setNotificationError(null); try { const { data } = await api.put("/notification-settings", submitted); if (!session.mounted || generation !== session.generation) return; setSavedNotificationPrefs(data); setNotificationPrefs(current => current?.emailFollowUpRemindersEnabled === submitted.emailFollowUpRemindersEnabled ? data : current); toast(currentTranslation.current("settingsNotificationsSaved"), "success"); } catch (cause) { if (session.mounted && generation === session.generation) setNotificationError({ cause, action: true }); } finally { if (generation === session.generation) { session.saving = false; if (session.mounted) setSavingNotifications(false); } } }; const discardNotifications = () => { if (!savedNotificationPrefs || notificationSession.current.saving) return; setNotificationPrefs(savedNotificationPrefs); setNotificationError(null); }; const notificationReadStatus = (notificationError?.cause as { response?: { status?: number } } | undefined)?.response?.status; return ( {t("settingsNavigation")} selectSection(v)} variant="scrollable" scrollButtons="auto" sx={{ display: { xs: "none", sm: "flex" }, mb: 1 }}> {rulesDirty ? {t("assetsUnsavedChanges")}. {t("rulesDraftScope")} : null} {t("settingsTheme")} {t("settingsPreferredLanguage")} {t("settingsPagination")} {t("settingsRowsPerPage")} {t("settingsColumns")} {( [ ["status", t("settingsColumnStatus")], ["dateApplied", t("settingsColumnDateApplied")], ["daysSince", t("settingsColumnDays")], ["jobUrl", t("settingsColumnJobUrl")], ] as const ).map(([key, label]) => ( onColumnsChange({ ...columns, [key]: !columns[key] })} />} label={label} /> ))} navigate("/settings/connected-accounts")}>{t("settingsManageConnectedAccounts")}} /> void saveRules()} onRetry={() => void loadRules()} onDiscard={discardRules} onChange={(field, value) => { setRulesDraft(current => current ? { ...current, [field]: value } : current); setInvalidRules(current => current.filter(key => key !== field)); }} /> {!notificationPrefs ? : {notificationError?.action ? {getApiErrorMessage(notificationError.cause, t("settingsNotificationsSaveFailed"))} : null} {notificationsDirty ? {t("assetsUnsavedChanges")}. {t("settingsNotificationsDraftScope")} : null} setNotificationPrefs({ emailFollowUpRemindersEnabled: e.target.checked })} />} label={t("settingsNotificationsFollowUpReminders")} /> {t("settingsNotificationsEmailBody")} } {t("settingsNotificationsDelivery")} ); }