AddNotificationModal.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. import { useState, useEffect } from 'react';
  2. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  3. import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
  6. import { Button } from './Button';
  7. import { Toggle } from './Toggle';
  8. interface AddNotificationModalProps {
  9. provider?: NotificationProvider | null;
  10. onClose: () => void;
  11. }
  12. const PROVIDER_OPTIONS: { value: ProviderType; label: string; description: string }[] = [
  13. { value: 'email', label: 'Email', description: 'SMTP email notifications' },
  14. { value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
  15. { value: 'discord', label: 'Discord', description: 'Send to Discord channel via webhook' },
  16. { value: 'ntfy', label: 'ntfy', description: 'Free, self-hostable push notifications' },
  17. { value: 'pushover', label: 'Pushover', description: 'Simple, reliable push notifications' },
  18. { value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },
  19. { value: 'webhook', label: 'Webhook', description: 'Generic HTTP POST to any URL' },
  20. ];
  21. export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
  22. const queryClient = useQueryClient();
  23. const isEditing = !!provider;
  24. const [name, setName] = useState(provider?.name || '');
  25. const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'email');
  26. const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
  27. const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
  28. const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
  29. const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
  30. // Daily digest
  31. const [dailyDigestEnabled, setDailyDigestEnabled] = useState(provider?.daily_digest_enabled || false);
  32. const [dailyDigestTime, setDailyDigestTime] = useState(provider?.daily_digest_time || '08:00');
  33. // Event toggles
  34. const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false);
  35. const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true);
  36. const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);
  37. const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
  38. const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
  39. const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
  40. const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
  41. const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
  42. const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
  43. // Provider-specific config
  44. const [config, setConfig] = useState<Record<string, string>>(
  45. provider?.config ? Object.fromEntries(Object.entries(provider.config).map(([k, v]) => [k, String(v)])) : {}
  46. );
  47. const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
  48. const [error, setError] = useState<string | null>(null);
  49. // Fetch printers for linking
  50. const { data: printers } = useQuery({
  51. queryKey: ['printers'],
  52. queryFn: api.getPrinters,
  53. });
  54. // Close on Escape key
  55. useEffect(() => {
  56. const handleKeyDown = (e: KeyboardEvent) => {
  57. if (e.key === 'Escape') onClose();
  58. };
  59. window.addEventListener('keydown', handleKeyDown);
  60. return () => window.removeEventListener('keydown', handleKeyDown);
  61. }, [onClose]);
  62. // Test configuration mutation
  63. const testMutation = useMutation({
  64. mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config }),
  65. onSuccess: (result) => {
  66. setTestResult(result);
  67. setError(null);
  68. },
  69. onError: (err: Error) => {
  70. setTestResult({ success: false, message: err.message });
  71. },
  72. });
  73. // Create mutation
  74. const createMutation = useMutation({
  75. mutationFn: (data: NotificationProviderCreate) => api.createNotificationProvider(data),
  76. onSuccess: () => {
  77. queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
  78. onClose();
  79. },
  80. onError: (err: Error) => {
  81. setError(err.message);
  82. },
  83. });
  84. // Update mutation
  85. const updateMutation = useMutation({
  86. mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider!.id, data),
  87. onSuccess: () => {
  88. queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
  89. onClose();
  90. },
  91. onError: (err: Error) => {
  92. setError(err.message);
  93. },
  94. });
  95. const handleSubmit = (e: React.FormEvent) => {
  96. e.preventDefault();
  97. setError(null);
  98. if (!name.trim()) {
  99. setError('Name is required');
  100. return;
  101. }
  102. // Validate provider-specific config
  103. const requiredFields = getRequiredFields(providerType);
  104. for (const field of requiredFields) {
  105. if (!config[field.key]?.trim()) {
  106. setError(`${field.label} is required`);
  107. return;
  108. }
  109. }
  110. const data = {
  111. name: name.trim(),
  112. provider_type: providerType,
  113. config,
  114. printer_id: printerId,
  115. quiet_hours_enabled: quietHoursEnabled,
  116. quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
  117. quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
  118. // Daily digest
  119. daily_digest_enabled: dailyDigestEnabled,
  120. daily_digest_time: dailyDigestEnabled ? dailyDigestTime : null,
  121. // Event toggles
  122. on_print_start: onPrintStart,
  123. on_print_complete: onPrintComplete,
  124. on_print_failed: onPrintFailed,
  125. on_print_stopped: onPrintStopped,
  126. on_print_progress: onPrintProgress,
  127. on_printer_offline: onPrinterOffline,
  128. on_printer_error: onPrinterError,
  129. on_filament_low: onFilamentLow,
  130. on_maintenance_due: onMaintenanceDue,
  131. };
  132. if (isEditing) {
  133. updateMutation.mutate(data);
  134. } else {
  135. createMutation.mutate(data);
  136. }
  137. };
  138. const isPending = createMutation.isPending || updateMutation.isPending;
  139. // Get config fields for each provider type
  140. const getConfigFields = (type: ProviderType) => {
  141. switch (type) {
  142. case 'callmebot':
  143. return [
  144. { key: 'phone', label: 'Phone Number', placeholder: '+1234567890', type: 'text', required: true },
  145. { key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'text', required: true },
  146. ];
  147. case 'ntfy':
  148. return [
  149. { key: 'server', label: 'Server URL', placeholder: 'https://ntfy.sh', type: 'text', required: false },
  150. { key: 'topic', label: 'Topic', placeholder: 'my-bambuddy', type: 'text', required: true },
  151. { key: 'auth_token', label: 'Auth Token', placeholder: 'Optional authentication', type: 'password', required: false },
  152. ];
  153. case 'pushover':
  154. return [
  155. { key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'text', required: true },
  156. { key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'text', required: true },
  157. { key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false },
  158. ];
  159. case 'telegram':
  160. return [
  161. { key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
  162. { key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },
  163. ];
  164. case 'email':
  165. return [
  166. { key: 'smtp_server', label: 'SMTP Server', placeholder: 'smtp.gmail.com', type: 'text', required: true },
  167. { key: 'smtp_port', label: 'SMTP Port', placeholder: '587', type: 'number', required: false },
  168. { key: 'security', label: 'Security', type: 'select', required: false, options: [
  169. { value: 'starttls', label: 'STARTTLS (Port 587)' },
  170. { value: 'ssl', label: 'SSL/TLS (Port 465)' },
  171. { value: 'none', label: 'None (Port 25)' },
  172. ]},
  173. { key: 'auth_enabled', label: 'Authentication', type: 'select', required: false, options: [
  174. { value: 'true', label: 'Enabled' },
  175. { value: 'false', label: 'Disabled' },
  176. ]},
  177. { key: 'username', label: 'Username', placeholder: 'your@email.com', type: 'text', required: false },
  178. { key: 'password', label: 'Password', placeholder: 'App password', type: 'password', required: false },
  179. { key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true },
  180. { key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true },
  181. ];
  182. case 'discord':
  183. return [
  184. { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'text', required: true },
  185. ];
  186. case 'webhook':
  187. return [
  188. { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'text', required: true },
  189. { key: 'payload_format', label: 'Payload Format', type: 'select', required: false, options: [
  190. { value: 'generic', label: 'Generic JSON' },
  191. { value: 'slack', label: 'Slack / Mattermost' },
  192. ]},
  193. { key: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false },
  194. { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false, showIf: (cfg: Record<string, string>) => cfg.payload_format !== 'slack' },
  195. { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false, showIf: (cfg: Record<string, string>) => cfg.payload_format !== 'slack' },
  196. ];
  197. default:
  198. return [];
  199. }
  200. };
  201. const getRequiredFields = (type: ProviderType) => {
  202. return getConfigFields(type).filter(f => f.required);
  203. };
  204. const configFields = getConfigFields(providerType);
  205. return (
  206. <div
  207. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto"
  208. onClick={onClose}
  209. >
  210. <div
  211. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8 max-h-[90vh] overflow-y-auto"
  212. onClick={(e) => e.stopPropagation()}
  213. >
  214. {/* Header */}
  215. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  216. <h2 className="text-lg font-semibold text-white">
  217. {isEditing ? 'Edit Notification Provider' : 'Add Notification Provider'}
  218. </h2>
  219. <button
  220. onClick={onClose}
  221. className="text-bambu-gray hover:text-white transition-colors"
  222. >
  223. <X className="w-5 h-5" />
  224. </button>
  225. </div>
  226. {/* Form */}
  227. <form onSubmit={handleSubmit} className="p-6 space-y-4">
  228. {error && (
  229. <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
  230. {error}
  231. </div>
  232. )}
  233. {/* Name */}
  234. <div>
  235. <label className="block text-sm text-bambu-gray mb-1">Name *</label>
  236. <input
  237. type="text"
  238. value={name}
  239. onChange={(e) => setName(e.target.value)}
  240. placeholder="My Notifications"
  241. 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"
  242. />
  243. </div>
  244. {/* Provider Type */}
  245. <div>
  246. <label className="block text-sm text-bambu-gray mb-1">Provider Type *</label>
  247. <select
  248. value={providerType}
  249. onChange={(e) => {
  250. setProviderType(e.target.value as ProviderType);
  251. setConfig({}); // Reset config when changing type
  252. setTestResult(null);
  253. }}
  254. disabled={isEditing}
  255. 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 disabled:opacity-50"
  256. >
  257. {PROVIDER_OPTIONS.map((option) => (
  258. <option key={option.value} value={option.value}>
  259. {option.label}
  260. </option>
  261. ))}
  262. </select>
  263. <p className="text-xs text-bambu-gray mt-1">
  264. {PROVIDER_OPTIONS.find(o => o.value === providerType)?.description}
  265. </p>
  266. </div>
  267. {/* Provider-specific configuration */}
  268. <div className="space-y-3">
  269. <p className="text-sm text-bambu-gray">Configuration</p>
  270. {configFields
  271. .filter((field) => !('showIf' in field) || (field as { showIf?: (cfg: Record<string, string>) => boolean }).showIf?.(config) !== false)
  272. .map((field) => (
  273. <div key={field.key}>
  274. <label className="block text-sm text-bambu-gray mb-1">
  275. {field.label} {field.required && '*'}
  276. </label>
  277. {field.type === 'select' && 'options' in field && field.options ? (
  278. <select
  279. value={config[field.key] || field.options[0]?.value || ''}
  280. onChange={(e) => {
  281. setConfig({ ...config, [field.key]: e.target.value });
  282. setTestResult(null);
  283. }}
  284. 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"
  285. >
  286. {field.options.map((opt) => (
  287. <option key={opt.value} value={opt.value}>
  288. {opt.label}
  289. </option>
  290. ))}
  291. </select>
  292. ) : (
  293. <input
  294. type={field.type}
  295. value={config[field.key] || ''}
  296. onChange={(e) => {
  297. setConfig({ ...config, [field.key]: e.target.value });
  298. setTestResult(null);
  299. }}
  300. placeholder={field.placeholder}
  301. 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"
  302. />
  303. )}
  304. </div>
  305. ))}
  306. </div>
  307. {/* Test Button */}
  308. <div className="flex gap-2">
  309. <Button
  310. type="button"
  311. variant="secondary"
  312. onClick={() => {
  313. setTestResult(null);
  314. testMutation.mutate();
  315. }}
  316. disabled={testMutation.isPending || !config[getRequiredFields(providerType)[0]?.key]}
  317. className="flex-1"
  318. >
  319. {testMutation.isPending ? (
  320. <Loader2 className="w-4 h-4 animate-spin" />
  321. ) : (
  322. <Send className="w-4 h-4" />
  323. )}
  324. Test Configuration
  325. </Button>
  326. </div>
  327. {/* Test Result */}
  328. {testResult && (
  329. <div className={`p-3 rounded-lg flex items-center gap-2 ${
  330. testResult.success
  331. ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
  332. : 'bg-red-500/20 border border-red-500/50 text-red-400'
  333. }`}>
  334. {testResult.success ? (
  335. <>
  336. <CheckCircle className="w-5 h-5" />
  337. <span>{testResult.message}</span>
  338. </>
  339. ) : (
  340. <>
  341. <XCircle className="w-5 h-5" />
  342. <span>{testResult.message}</span>
  343. </>
  344. )}
  345. </div>
  346. )}
  347. {/* Link to Printer */}
  348. <div>
  349. <label className="block text-sm text-bambu-gray mb-1">Printer Filter</label>
  350. <select
  351. value={printerId ?? ''}
  352. onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
  353. 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"
  354. >
  355. <option value="">All printers</option>
  356. {printers?.map((p) => (
  357. <option key={p.id} value={p.id}>
  358. {p.name}
  359. </option>
  360. ))}
  361. </select>
  362. <p className="text-xs text-bambu-gray mt-1">
  363. Only send notifications for events from this printer
  364. </p>
  365. </div>
  366. {/* Quiet Hours */}
  367. <div className="space-y-2">
  368. <div className="flex items-center justify-between">
  369. <label className="text-sm text-white">Quiet Hours (Do Not Disturb)</label>
  370. <Toggle
  371. checked={quietHoursEnabled}
  372. onChange={setQuietHoursEnabled}
  373. />
  374. </div>
  375. {quietHoursEnabled && (
  376. <div className="grid grid-cols-2 gap-3">
  377. <div>
  378. <label className="block text-xs text-bambu-gray mb-1">Start</label>
  379. <input
  380. type="time"
  381. value={quietHoursStart}
  382. onChange={(e) => setQuietHoursStart(e.target.value)}
  383. 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"
  384. />
  385. </div>
  386. <div>
  387. <label className="block text-xs text-bambu-gray mb-1">End</label>
  388. <input
  389. type="time"
  390. value={quietHoursEnd}
  391. onChange={(e) => setQuietHoursEnd(e.target.value)}
  392. 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"
  393. />
  394. </div>
  395. </div>
  396. )}
  397. </div>
  398. {/* Daily Digest */}
  399. <div className="space-y-2">
  400. <div className="flex items-center justify-between">
  401. <div>
  402. <label className="text-sm text-white">Daily Digest</label>
  403. <p className="text-xs text-bambu-gray">Batch notifications into a single daily summary</p>
  404. </div>
  405. <Toggle
  406. checked={dailyDigestEnabled}
  407. onChange={setDailyDigestEnabled}
  408. />
  409. </div>
  410. {dailyDigestEnabled && (
  411. <div>
  412. <label className="block text-xs text-bambu-gray mb-1">Send digest at</label>
  413. <input
  414. type="time"
  415. value={dailyDigestTime}
  416. onChange={(e) => setDailyDigestTime(e.target.value)}
  417. 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"
  418. />
  419. <p className="text-xs text-bambu-gray mt-1">
  420. Events will be collected and sent as a single summary at this time
  421. </p>
  422. </div>
  423. )}
  424. </div>
  425. {/* Event Toggles */}
  426. <div className="space-y-3">
  427. <p className="text-sm text-bambu-gray">Notification Events</p>
  428. {/* Print Events */}
  429. <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
  430. <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Print Events</p>
  431. <div className="grid grid-cols-2 gap-2">
  432. <div className="flex items-center justify-between">
  433. <span className="text-sm text-white">Start</span>
  434. <Toggle checked={onPrintStart} onChange={setOnPrintStart} />
  435. </div>
  436. <div className="flex items-center justify-between">
  437. <span className="text-sm text-white">Complete</span>
  438. <Toggle checked={onPrintComplete} onChange={setOnPrintComplete} />
  439. </div>
  440. <div className="flex items-center justify-between">
  441. <span className="text-sm text-white">Failed</span>
  442. <Toggle checked={onPrintFailed} onChange={setOnPrintFailed} />
  443. </div>
  444. <div className="flex items-center justify-between">
  445. <span className="text-sm text-white">Stopped</span>
  446. <Toggle checked={onPrintStopped} onChange={setOnPrintStopped} />
  447. </div>
  448. <div className="flex items-center justify-between col-span-2">
  449. <div>
  450. <span className="text-sm text-white">Progress</span>
  451. <span className="text-xs text-bambu-gray ml-1">(25%, 50%, 75%)</span>
  452. </div>
  453. <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
  454. </div>
  455. </div>
  456. </div>
  457. {/* Printer Status Events */}
  458. <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
  459. <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Printer Status</p>
  460. <div className="grid grid-cols-2 gap-2">
  461. <div className="flex items-center justify-between">
  462. <span className="text-sm text-white">Offline</span>
  463. <Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
  464. </div>
  465. <div className="flex items-center justify-between">
  466. <span className="text-sm text-white">Error</span>
  467. <Toggle checked={onPrinterError} onChange={setOnPrinterError} />
  468. </div>
  469. <div className="flex items-center justify-between">
  470. <span className="text-sm text-white">Low Filament</span>
  471. <Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
  472. </div>
  473. <div className="flex items-center justify-between">
  474. <span className="text-sm text-white">Maintenance</span>
  475. <Toggle checked={onMaintenanceDue} onChange={setOnMaintenanceDue} />
  476. </div>
  477. </div>
  478. </div>
  479. </div>
  480. {/* Actions */}
  481. <div className="flex gap-3 pt-2">
  482. <Button
  483. type="button"
  484. variant="secondary"
  485. onClick={onClose}
  486. className="flex-1"
  487. >
  488. Cancel
  489. </Button>
  490. <Button
  491. type="submit"
  492. disabled={isPending}
  493. className="flex-1"
  494. >
  495. {isPending ? (
  496. <Loader2 className="w-4 h-4 animate-spin" />
  497. ) : (
  498. <Save className="w-4 h-4" />
  499. )}
  500. {isEditing ? 'Save' : 'Add'}
  501. </Button>
  502. </div>
  503. </form>
  504. </div>
  505. </div>
  506. );
  507. }