import { useState, useEffect } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react'; import { api } from '../api/client'; import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client'; import { Button } from './Button'; import { Toggle } from './Toggle'; interface AddNotificationModalProps { provider?: NotificationProvider | null; onClose: () => void; } const PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'callmebot', 'webhook', 'homeassistant']; export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const isEditing = !!provider; const [name, setName] = useState(provider?.name || ''); const [providerType, setProviderType] = useState(provider?.provider_type || 'email'); const [printerId, setPrinterId] = useState(provider?.printer_id || null); const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false); const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00'); const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00'); // Daily digest const [dailyDigestEnabled, setDailyDigestEnabled] = useState(provider?.daily_digest_enabled || false); const [dailyDigestTime, setDailyDigestTime] = useState(provider?.daily_digest_time || '08:00'); // Event toggles const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false); const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true); const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true); const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true); const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false); const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false); const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false); const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false); const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false); const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false); const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false); const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false); const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false); // Provider-specific config (scalar fields only — event_priorities is split out // into its own state because it's an object, not a string). const [config, setConfig] = useState>( provider?.config ? Object.fromEntries( Object.entries(provider.config) .filter(([k]) => k !== 'event_priorities') .map(([k, v]) => [k, String(v)]), ) : {}, ); // Per-event ntfy priority (#990). Map of event key → 1-5. Persisted into // config.event_priorities on save; only sent when the provider is ntfy. const initialEventPriorities = (() => { const raw = provider?.config?.event_priorities; if (!raw || typeof raw !== 'object') return {} as Record; const out: Record = {}; for (const [k, v] of Object.entries(raw as Record)) { const n = Number(v); if (Number.isInteger(n) && n >= 1 && n <= 5) out[k] = n; } return out; })(); const [eventPriorities, setEventPriorities] = useState>(initialEventPriorities); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [error, setError] = useState(null); // Fetch printers for linking const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); // Test configuration mutation const testMutation = useMutation({ mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config }), onSuccess: (result) => { setTestResult(result); setError(null); }, onError: (err: Error) => { setTestResult({ success: false, message: err.message }); }, }); // Create mutation const createMutation = useMutation({ mutationFn: (data: NotificationProviderCreate) => api.createNotificationProvider(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['notification-providers'] }); onClose(); }, onError: (err: Error) => { setError(err.message); }, }); // Update mutation const updateMutation = useMutation({ mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider!.id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['notification-providers'] }); onClose(); }, onError: (err: Error) => { setError(err.message); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(null); if (!name.trim()) { setError(t('notifications.nameRequired')); return; } // Validate provider-specific config const requiredFields = getRequiredFields(providerType); for (const field of requiredFields) { if (!config[field.key]?.trim()) { setError(t('notifications.fieldRequired', { field: field.label })); return; } } const finalConfig: Record = providerType === 'ntfy' && Object.keys(eventPriorities).length > 0 ? { ...config, event_priorities: eventPriorities } : config; const data = { name: name.trim(), provider_type: providerType, config: finalConfig, printer_id: printerId, quiet_hours_enabled: quietHoursEnabled, quiet_hours_start: quietHoursEnabled ? quietHoursStart : null, quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null, // Daily digest daily_digest_enabled: dailyDigestEnabled, daily_digest_time: dailyDigestEnabled ? dailyDigestTime : null, // Event toggles on_print_start: onPrintStart, on_print_complete: onPrintComplete, on_print_failed: onPrintFailed, on_print_stopped: onPrintStopped, on_print_progress: onPrintProgress, on_printer_offline: onPrinterOffline, on_printer_error: onPrinterError, on_filament_low: onFilamentLow, on_maintenance_due: onMaintenanceDue, on_stock_reorder_alert: onStockReorderAlert, on_stock_break_alert: onStockBreakAlert, on_bed_cooled: onBedCooled, on_first_layer_complete: onFirstLayerComplete, }; if (isEditing) { updateMutation.mutate(data); } else { createMutation.mutate(data); } }; const isPending = createMutation.isPending || updateMutation.isPending; // Get config fields for each provider type const getConfigFields = (type: ProviderType) => { switch (type) { case 'callmebot': return [ { key: 'phone', label: 'Phone Number', placeholder: '+1234567890', type: 'text', required: true }, { key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'text', required: true }, ]; case 'ntfy': return [ { key: 'server', label: 'Server URL', placeholder: 'https://ntfy.sh', type: 'text', required: false }, { key: 'topic', label: 'Topic', placeholder: 'my-bambuddy', type: 'text', required: true }, { key: 'auth_token', label: 'Auth Token', placeholder: 'Optional authentication', type: 'password', required: false }, ]; case 'pushover': return [ { key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'text', required: true }, { key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'text', required: true }, { key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false }, ]; case 'telegram': return [ { key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true }, { key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true }, ]; case 'email': return [ { key: 'smtp_server', label: 'SMTP Server', placeholder: 'smtp.gmail.com', type: 'text', required: true }, { key: 'smtp_port', label: 'SMTP Port', placeholder: '587', type: 'number', required: false }, { key: 'security', label: 'Security', type: 'select', required: false, options: [ { value: 'starttls', label: 'STARTTLS (Port 587)' }, { value: 'ssl', label: 'SSL/TLS (Port 465)' }, { value: 'none', label: 'None (Port 25)' }, ]}, { key: 'auth_enabled', label: 'Authentication', type: 'select', required: false, options: [ { value: 'true', label: 'Enabled' }, { value: 'false', label: 'Disabled' }, ]}, { key: 'username', label: 'Username', placeholder: 'your@email.com', type: 'text', required: false }, { key: 'password', label: 'Password', placeholder: 'App password', type: 'password', required: false }, { key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true }, { key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true }, ]; case 'discord': return [ { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'text', required: true }, ]; case 'webhook': return [ { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'text', required: true }, { key: 'payload_format', label: 'Payload Format', type: 'select', required: false, options: [ { value: 'generic', label: 'Generic JSON' }, { value: 'slack', label: 'Slack / Mattermost' }, ]}, { key: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false }, { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false, showIf: (cfg: Record) => cfg.payload_format !== 'slack' }, { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false, showIf: (cfg: Record) => cfg.payload_format !== 'slack' }, ]; case 'homeassistant': return [ { key: 'service', label: 'Home Assistant Service', placeholder: 'notify.mobile_app_myphone', type: 'text', required: false }, ]; default: return []; } }; const getRequiredFields = (type: ProviderType) => { return getConfigFields(type).filter(f => f.required); }; const configFields = getConfigFields(providerType); return (
e.stopPropagation()} > {/* Header */}

{isEditing ? t('notifications.editTitle') : t('notifications.addTitle')}

{/* Form */}
{error && (
{error}
)} {/* Name */}
setName(e.target.value)} placeholder={t('notifications.namePlaceholder')} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
{/* Provider Type */}

{t(`notifications.providerDescriptions.${providerType}`, '')}

{/* Provider-specific configuration */}

{t('notifications.configuration')}

{configFields .filter((field) => !('showIf' in field) || (field as { showIf?: (cfg: Record) => boolean }).showIf?.(config) !== false) .map((field) => (
{field.type === 'select' && 'options' in field && field.options ? ( ) : ( { setConfig({ ...config, [field.key]: e.target.value }); setTestResult(null); }} placeholder={field.placeholder} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" /> )}
))}
{/* Test Button */}
{/* Test Result */} {testResult && (
{testResult.success ? ( <> {testResult.message} ) : ( <> {testResult.message} )}
)} {/* Link to Printer */}

{t('notifications.onlyFromPrinter')}

{/* Quiet Hours */}
{quietHoursEnabled && (
setQuietHoursStart(e.target.value)} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
setQuietHoursEnd(e.target.value)} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />
)}
{/* Daily Digest */}

{t('notifications.batchNotifications')}

{dailyDigestEnabled && (
setDailyDigestTime(e.target.value)} className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" />

{t('notifications.digestCollected')}

)}
{/* Event Toggles */}

{t('notifications.notificationEvents')}

{/* Print Events */}

{t('notifications.printEvents')}

{t('notifications.start')}
{t('notifications.complete')}
{t('notifications.failed')}
{t('notifications.stopped')}
{t('notifications.progress')} {t('notifications.progressPercent')}
{t('notifications.bedCooled')} {t('notifications.bedCooledAfterPrint')}
{t('notifications.firstLayerCompleteLabel')} {t('notifications.firstLayerCompleteDescription')}
{/* Printer Status Events */}

{t('notifications.printerStatus')}

{t('notifications.offline')}
{t('notifications.error')}
{t('notifications.lowFilament')}
{t('notifications.maintenance')}
{/* Inventory Stock Alerts */}

{t('notifications.inventoryAlerts')}

{t('notifications.stockReorderAlert')} {t('notifications.stockReorderAlertDescription')}
{t('notifications.stockBreakAlert')} {t('notifications.stockBreakAlertDescription')}
{/* Per-event ntfy priority (#990) */} {providerType === 'ntfy' && (() => { const enabledEvents: Array<{ key: string; label: string }> = []; if (onPrintStart) enabledEvents.push({ key: 'on_print_start', label: t('notifications.start') }); if (onPrintComplete) enabledEvents.push({ key: 'on_print_complete', label: t('notifications.complete') }); if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') }); if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') }); if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') }); if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') }); if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') }); if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') }); if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') }); if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') }); if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') }); if (onStockReorderAlert) enabledEvents.push({ key: 'on_stock_reorder_alert', label: t('notifications.stockReorderAlert') }); if (onStockBreakAlert) enabledEvents.push({ key: 'on_stock_break_alert', label: t('notifications.stockBreakAlert') }); if (enabledEvents.length === 0) return null; return (

{t('notifications.eventPriority.sectionTitle')}

{t('notifications.eventPriority.helpNtfy')}

{enabledEvents.map((ev) => (
{ev.label}
))}
); })()}
{/* Actions */}
); }