"use client"; import * as React from "react"; import { toast } from "sonner"; import { useTheme } from "next-themes"; import { Loader2, Save, Wifi, WifiOff, Trash2, Brain, FolderSearch, HardDrive, Settings, Sun, Moon, Monitor, Bell, Mail, LayoutGrid, Globe, Check, RefreshCw, } from "lucide-react"; import { Header } from "@/components/layout/header"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; const AI_PROVIDERS = [ { value: "gateway", label: "Vercel AI Gateway", defaultModel: "openai/gpt-4o-mini", hint: "openai/gpt-4o-mini, google/gemini-2.5-flash, anthropic/claude-sonnet-4-20250514", }, { value: "ollama", label: "Ollama (Local)", defaultModel: "llava:7b", hint: "llava:7b, moondream, llama3.2-vision", }, ] as const; import { Checkbox } from "@/components/ui/checkbox"; type MondayColumn = { id: string; title: string; type: string }; const MONDAY_MAPPABLE_FIELDS = [ { field: "name", label: "Name" }, { field: "gender", label: "Gender" }, { field: "dateOfBirth", label: "Date of Birth" }, { field: "maritalStatus", label: "Marital Status" }, { field: "visitType", label: "Visit Type" }, { field: "followUp", label: "Follow-Up" }, { field: "cellPhone", label: "Cell Phone" }, { field: "homePhone", label: "Home Phone" }, { field: "email", label: "Email" }, { field: "address", label: "Address" }, { field: "aptNumber", label: "Apt #" }, { field: "city", label: "City" }, { field: "state", label: "State" }, { field: "zip", label: "Zip" }, { field: "prayerRequests", label: "Prayer Requests" }, { field: "prayerForTeam", label: "For Prayer Team" }, { field: "prayerConfidential", label: "Confidential" }, { field: "messageTopics", label: "Message Topics" }, { field: "messageTopicsOther", label: "Other - Topics" }, { field: "nextStep", label: "Next Step" }, { field: "attendanceDuration", label: "Attendance Duration" }, { field: "campusPreference", label: "Campus Preference" }, { field: "campusPreferenceOther", label: "Other Location" }, { field: "howHeard", label: "How Did You Hear" }, { field: "howHeardOther", label: "Other - How Heard" }, { field: "serviceAttended", label: "A B C D" }, { field: "serviceTime", label: "Service Time" }, { field: "notes", label: "Notes" }, { field: "planningCenter", label: "Planning Center" }, { field: "iSaidYesBookSent", label: "I Said Yes Book Sent" }, { field: "ftGuestLetterSent", label: "FT Guest Letter Sent" }, { field: "reviewStatus", label: "Review Status" }, ] as const; type SettingsData = { ollamaUrl: string; model: string; watchDir: string; watching: boolean; sourceRetentionDays: number; imageRetentionDays: number; aiProvider: string; aiModel: string; emailImapHost: string; emailImapPort: number; emailImapUser: string; emailImapPass: string; emailImapTls: boolean; emailFolder: string; emailWatching: boolean; emailProcessed: string; emailProcessedFolder: string; mondayApiToken: string; mondayBoardId: string; mondayEnabled: boolean; mondayColumnMap: Record | null; mondayWebhookId: string; mondayWebhookUrl: string; webhookUrl: string; webhookSecret: string; webhookEnabled: boolean; webhookEvents: string[] | null; }; const NOTIFICATION_STORAGE_KEY = "echo-ocr-notification-prefs"; type NotificationPrefs = { processingComplete: boolean; processingErrors: boolean; folderWatchAlerts: boolean; cleanupReminders: boolean; }; const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { processingComplete: true, processingErrors: true, folderWatchAlerts: true, cleanupReminders: false, }; function loadNotificationPrefs(): NotificationPrefs { if (typeof window === "undefined") return DEFAULT_NOTIFICATION_PREFS; try { const raw = localStorage.getItem(NOTIFICATION_STORAGE_KEY); if (raw) return { ...DEFAULT_NOTIFICATION_PREFS, ...JSON.parse(raw) }; } catch {} return DEFAULT_NOTIFICATION_PREFS; } export default function SettingsPage() { const { theme, setTheme } = useTheme(); const [settings, setSettings] = React.useState({ ollamaUrl: "", model: "", watchDir: "", watching: false, sourceRetentionDays: 30, imageRetentionDays: 180, aiProvider: "gateway", aiModel: "", emailImapHost: "imap.dreamhost.com", emailImapPort: 993, emailImapUser: "echo-ocr@stillwell.cloud", emailImapPass: "", emailImapTls: true, emailFolder: "INBOX", emailWatching: false, emailProcessed: "mark_read", emailProcessedFolder: "Processed", mondayApiToken: "", mondayBoardId: "", mondayEnabled: false, mondayColumnMap: null, mondayWebhookId: "", mondayWebhookUrl: "", webhookUrl: "", webhookSecret: "", webhookEnabled: false, webhookEvents: null, }); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [aiTestStatus, setAiTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle"); const [cleanupStatus, setCleanupStatus] = React.useState<{ sourcesEligible: number; imagesEligible: number } | null>(null); const [cleaning, setCleaning] = React.useState(false); const [emailTestStatus, setEmailTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle"); const [mondayColumns, setMondayColumns] = React.useState([]); const [fetchingColumns, setFetchingColumns] = React.useState(false); const [subscribing, setSubscribing] = React.useState(false); const [pushingAll, setPushingAll] = React.useState(false); const [webhookTestStatus, setWebhookTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle"); const [recoverCount, setRecoverCount] = React.useState(null); const [recovering, setRecovering] = React.useState(false); const [notifPrefs, setNotifPrefs] = React.useState(DEFAULT_NOTIFICATION_PREFS); React.useEffect(() => { setNotifPrefs(loadNotificationPrefs()); }, []); const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => { setNotifPrefs((prev) => { const next = { ...prev, [key]: value }; try { localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(next)); } catch {} return next; }); toast.success("Notification preference updated"); }; const fetchCleanupStatus = React.useCallback(() => { fetch("/api/cleanup") .then((r) => r.json()) .then((data) => { if (data.sourcesEligible !== undefined) setCleanupStatus(data); }) .catch(() => {}); }, []); React.useEffect(() => { fetch("/api/settings") .then((r) => r.json()) .then((data) => { setSettings(data); setLoading(false); }) .catch(() => { setLoading(false); }); fetchCleanupStatus(); fetch("/api/cards/recover-survey") .then((r) => r.json()) .then((data) => { if (data.missingCount !== undefined) setRecoverCount(data.missingCount); }) .catch(() => {}); }, [fetchCleanupStatus]); const handleSave = async () => { setSaving(true); try { const res = await fetch("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(settings), }); if (!res.ok) throw new Error(); toast.success("Settings saved"); } catch { toast.error("Failed to save settings"); } finally { setSaving(false); } }; const testAiProvider = async () => { setAiTestStatus("testing"); try { const res = await fetch("/api/ai-test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: settings.aiProvider, model: settings.aiModel, ollamaUrl: settings.ollamaUrl, }), signal: AbortSignal.timeout(15000), }); if (res.ok) { setAiTestStatus("success"); toast.success("AI provider connected successfully"); } else { const data = await res.json().catch(() => ({})); setAiTestStatus("error"); toast.error(data.error || "AI provider test failed"); } } catch { setAiTestStatus("error"); toast.error("Cannot reach AI provider. Check your configuration and API keys."); } }; const handleProviderChange = (value: string | null) => { if (!value) return; const provider = AI_PROVIDERS.find((p) => p.value === value); setSettings((s) => ({ ...s, aiProvider: value, aiModel: provider?.defaultModel || "", })); setAiTestStatus("idle"); }; const toggleWatch = async () => { try { const action = settings.watching ? "stop" : "start"; const res = await fetch("/api/watch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, watchDir: settings.watchDir }), }); if (!res.ok) throw new Error(); setSettings((s) => ({ ...s, watching: !s.watching })); toast.success(action === "start" ? "Folder watching started" : "Folder watching stopped"); } catch { toast.error("Failed to toggle folder watching"); } }; const toggleEmailWatch = async () => { try { const action = settings.emailWatching ? "stop" : "start"; const res = await fetch("/api/email-watch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "Failed"); } setSettings((s) => ({ ...s, emailWatching: !s.emailWatching })); toast.success(action === "start" ? "Email monitoring started" : "Email monitoring stopped"); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to toggle email monitoring"); } }; const testEmailConnection = async () => { setEmailTestStatus("testing"); try { const res = await fetch("/api/email-watch/test", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ host: settings.emailImapHost, port: settings.emailImapPort, user: settings.emailImapUser, pass: settings.emailImapPass, tls: settings.emailImapTls, }), signal: AbortSignal.timeout(15000), }); const data = await res.json().catch(() => ({})); if (res.ok && data.ok) { setEmailTestStatus("success"); toast.success(data.message || "Connected successfully"); } else { setEmailTestStatus("error"); toast.error(data.error || "Connection failed"); } } catch { setEmailTestStatus("error"); toast.error("Cannot reach email server. Check your configuration."); } }; const fetchMondayColumns = async () => { setFetchingColumns(true); try { const res = await fetch("/api/integrations/monday/columns", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token: settings.mondayApiToken, boardId: settings.mondayBoardId }), }); const data = await res.json(); if (res.ok && data.columns) { setMondayColumns(data.columns); const typeMap: Record = {}; for (const col of data.columns as MondayColumn[]) { typeMap[col.id] = col.type; } setSettings((s) => ({ ...s, mondayColumnMap: { ...(s.mondayColumnMap || {}), _columnTypes: typeMap }, })); toast.success(`Found ${data.columns.length} columns`); } else { toast.error(data.error || "Failed to fetch columns"); } } catch { toast.error("Failed to connect to Monday.com"); } finally { setFetchingColumns(false); } }; const toggleMondaySubscription = async () => { setSubscribing(true); try { const isSubscribed = !!settings.mondayWebhookId; const callbackUrl = settings.mondayWebhookUrl || `${window.location.origin}/api/integrations/monday/webhook`; const res = await fetch("/api/integrations/monday/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: isSubscribed ? "unsubscribe" : "subscribe", callbackUrl, }), }); const data = await res.json(); if (res.ok) { setSettings((s) => ({ ...s, mondayWebhookId: data.webhookId || "", mondayWebhookUrl: isSubscribed ? "" : callbackUrl, })); toast.success(isSubscribed ? "Unsubscribed from Monday.com changes" : "Subscribed to Monday.com changes"); } else { toast.error(data.error || "Failed"); } } catch { toast.error("Subscription change failed"); } finally { setSubscribing(false); } }; const syncAllToMonday = async () => { setPushingAll(true); try { const res = await fetch("/api/integrations/monday/sync-all", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "all" }), }); const data = await res.json(); if (res.ok) { if (data.synced === 0 && data.failed === 0) { toast.success(data.message || "All cards are already synced"); } else { const parts: string[] = []; if (data.created > 0) parts.push(`${data.created} pushed`); if (data.updated > 0) parts.push(`${data.updated} updated`); if (data.failed > 0) parts.push(`${data.failed} failed`); const msg = parts.join(", "); if (data.failed > 0 && data.synced === 0) { toast.error(`Monday.com: ${msg}`, { description: data.errors?.[0] }); } else if (data.failed > 0) { toast.warning(`Monday.com: ${msg}`); } else { toast.success(`Monday.com: ${msg}`); } } } else { toast.error(data.error || "Sync failed"); } } catch { toast.error("Failed to sync cards to Monday.com"); } finally { setPushingAll(false); } }; const testWebhook = async () => { setWebhookTestStatus("testing"); try { const res = await fetch("/api/integrations/webhook/test", { method: "POST" }); const data = await res.json(); if (data.ok) { setWebhookTestStatus("success"); toast.success("Test webhook sent"); } else { setWebhookTestStatus("error"); toast.error(data.error || "Webhook test failed"); } } catch { setWebhookTestStatus("error"); toast.error("Webhook test failed"); } }; const setColumnMapping = (cardField: string, colId: string) => { setSettings((s) => ({ ...s, mondayColumnMap: { ...(s.mondayColumnMap || {}), [cardField]: colId }, })); }; const toggleWebhookEvent = (event: string) => { setSettings((s) => { const current = s.webhookEvents ?? []; const next = current.includes(event) ? current.filter((e) => e !== event) : [...current, event]; return { ...s, webhookEvents: next }; }); }; const recoverSurveys = async () => { setRecovering(true); try { const res = await fetch("/api/cards/recover-survey", { method: "POST" }); const data = await res.json(); if (res.ok) { toast.success(data.message || `Recovery started for ${data.queued} card(s)`); setRecoverCount(0); } else { toast.error(data.error || "Recovery failed"); } } catch { toast.error("Failed to start recovery"); } finally { setRecovering(false); } }; const runCleanup = async () => { setCleaning(true); try { const res = await fetch("/api/cleanup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dryRun: false }), }); if (!res.ok) throw new Error(); const data = await res.json(); toast.success( `Cleanup complete: ${data.sourcesDeleted} source files, ${data.imagesDeleted} images, ${data.jobsPurged} jobs removed` ); fetchCleanupStatus(); } catch { toast.error("Cleanup failed"); } finally { setCleaning(false); } }; if (loading) { return (
); } const themeOptions = [ { value: "light", label: "Light", icon: Sun, description: "Clean and bright interface" }, { value: "dark", label: "Dark", icon: Moon, description: "Easy on the eyes in low light" }, { value: "system", label: "System", icon: Monitor, description: "Follows your OS preference" }, ] as const; return (
Application Preferences
AI Provider Choose the AI model for OCR processing
{aiTestStatus === "success" && } {aiTestStatus === "error" && } {aiTestStatus === "testing" && } {aiTestStatus === "success" ? "Connected" : aiTestStatus === "error" ? "Error" : aiTestStatus === "testing" ? "Testing..." : "Not tested"}
setSettings((s) => ({ ...s, aiModel: e.target.value }))} placeholder={AI_PROVIDERS.find((p) => p.value === settings.aiProvider)?.defaultModel || ""} />

{AI_PROVIDERS.find((p) => p.value === settings.aiProvider)?.hint || ""}

{settings.aiProvider === "ollama" && (
setSettings((s) => ({ ...s, ollamaUrl: e.target.value }))} placeholder="http://192.168.68.108:11434" />
)} {settings.aiProvider === "gateway" && (

Uses the AI_GATEWAY_API_KEY env var. Model format: provider/model

)}
Folder Monitoring Automatically process new PDFs dropped into a folder
setSettings((s) => ({ ...s, watchDir: e.target.value }))} placeholder="/data/watch" />

Absolute path on the server. Mount a host folder into the container.

{settings.watching && ( Active )}
Storage & Cleanup Auto-purge uploaded files and images to save storage
setSettings((s) => ({ ...s, sourceRetentionDays: parseInt(e.target.value) || 30, })) } />

Original uploaded PDFs are deleted after this many days. Card data is kept.

setSettings((s) => ({ ...s, imageRetentionDays: parseInt(e.target.value) || 180, })) } />

Scanned card images are removed after this many days. Card data is kept.

{cleanupStatus ? ( <> {cleanupStatus.sourcesEligible} source files {" and "} {cleanupStatus.imagesEligible} card images eligible ) : ( "Checking..." )}
{recoverCount !== null && recoverCount > 0 && (
{recoverCount}{" "} card(s) are missing their survey (back) side. Re-extracts missing pages from the original source PDFs and runs OCR.
)}
Email Monitoring Watch an inbox for scanned card attachments
{emailTestStatus === "success" && } {emailTestStatus === "error" && } {emailTestStatus === "testing" && } {emailTestStatus === "success" ? "Connected" : emailTestStatus === "error" ? "Error" : emailTestStatus === "testing" ? "Testing..." : "Not tested"}
setSettings((s) => ({ ...s, emailImapHost: e.target.value }))} placeholder="imap.dreamhost.com" />
setSettings((s) => ({ ...s, emailImapPort: parseInt(e.target.value) || 993 }))} placeholder="993" />
setSettings((s) => ({ ...s, emailImapUser: e.target.value }))} placeholder="echo-ocr@stillwell.cloud" />
setSettings((s) => ({ ...s, emailImapPass: e.target.value }))} placeholder="••••••••" />
setSettings((s) => ({ ...s, emailFolder: e.target.value }))} placeholder="INBOX" />
{settings.emailProcessed === "move" && (
setSettings((s) => ({ ...s, emailProcessedFolder: e.target.value }))} placeholder="Processed" />
)}
setSettings((s) => ({ ...s, emailImapTls: val }))} />
{settings.emailWatching && ( Active )}
Monday.com Integration Bidirectional sync with Monday.com boards
{settings.mondayEnabled && ( Enabled )}
setSettings((s) => ({ ...s, mondayApiToken: e.target.value }))} placeholder="••••••••" />
setSettings((s) => ({ ...s, mondayBoardId: e.target.value }))} placeholder="1234567890" />
setSettings((s) => ({ ...s, mondayWebhookUrl: e.target.value }))} placeholder="https://your-app.com/api/integrations/monday/webhook" />
{settings.mondayWebhookId && ( Subscribed )}
{mondayColumns.length > 0 && (

Column Mapping

{MONDAY_MAPPABLE_FIELDS.map(({ field, label }) => (
{label}
))}
Files (images)
)}
setSettings((s) => ({ ...s, mondayEnabled: val }))} />
Webhook Integration POST card data to any external URL on events
{settings.webhookEnabled && ( Enabled )}
setSettings((s) => ({ ...s, webhookUrl: e.target.value }))} placeholder="https://example.com/webhook" />
setSettings((s) => ({ ...s, webhookSecret: e.target.value }))} placeholder="••••••••" />
{[ { value: "ocr_complete", label: "OCR Complete" }, { value: "card_reviewed", label: "Card Reviewed" }, { value: "card_exported", label: "Card Exported" }, { value: "card_deleted", label: "Card Deleted" }, ].map((evt) => ( ))}
setSettings((s) => ({ ...s, webhookEnabled: val }))} />
Appearance Choose how Echo OCR looks to you
{themeOptions.map((opt) => { const Icon = opt.icon; const active = theme === opt.value; return ( ); })}
Notifications Control which events generate toast notifications
{([ { key: "processingComplete" as const, title: "Processing complete", desc: "When a PDF finishes OCR processing" }, { key: "processingErrors" as const, title: "Processing errors", desc: "When a job fails or encounters an error" }, { key: "folderWatchAlerts" as const, title: "Folder watch alerts", desc: "When the folder watcher starts, stops, or finds new files" }, { key: "cleanupReminders" as const, title: "Cleanup reminders", desc: "Periodic reminders when files are eligible for cleanup" }, ]).map((item) => (

{item.title}

{item.desc}

updateNotifPref(item.key, val)} />
))}
); }