AddNotificationModal.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  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: 'discord', label: 'Discord', description: 'Send to Discord channel via webhook' },
  14. { value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },
  15. { value: 'ntfy', label: 'ntfy', description: 'Free, self-hostable push notifications' },
  16. { value: 'pushover', label: 'Pushover', description: 'Simple, reliable push notifications' },
  17. { value: 'email', label: 'Email', description: 'SMTP email 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 || 'discord');
  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-bambutrack', 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: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false },
  190. { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false },
  191. { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false },
  192. ];
  193. default:
  194. return [];
  195. }
  196. };
  197. const getRequiredFields = (type: ProviderType) => {
  198. return getConfigFields(type).filter(f => f.required);
  199. };
  200. const configFields = getConfigFields(providerType);
  201. return (
  202. <div
  203. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto"
  204. onClick={onClose}
  205. >
  206. <div
  207. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8"
  208. onClick={(e) => e.stopPropagation()}
  209. >
  210. {/* Header */}
  211. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  212. <h2 className="text-lg font-semibold text-white">
  213. {isEditing ? 'Edit Notification Provider' : 'Add Notification Provider'}
  214. </h2>
  215. <button
  216. onClick={onClose}
  217. className="text-bambu-gray hover:text-white transition-colors"
  218. >
  219. <X className="w-5 h-5" />
  220. </button>
  221. </div>
  222. {/* Form */}
  223. <form onSubmit={handleSubmit} className="p-6 space-y-4">
  224. {error && (
  225. <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
  226. {error}
  227. </div>
  228. )}
  229. {/* Name */}
  230. <div>
  231. <label className="block text-sm text-bambu-gray mb-1">Name *</label>
  232. <input
  233. type="text"
  234. value={name}
  235. onChange={(e) => setName(e.target.value)}
  236. placeholder="My Notifications"
  237. 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"
  238. />
  239. </div>
  240. {/* Provider Type */}
  241. <div>
  242. <label className="block text-sm text-bambu-gray mb-1">Provider Type *</label>
  243. <select
  244. value={providerType}
  245. onChange={(e) => {
  246. setProviderType(e.target.value as ProviderType);
  247. setConfig({}); // Reset config when changing type
  248. setTestResult(null);
  249. }}
  250. disabled={isEditing}
  251. 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"
  252. >
  253. {PROVIDER_OPTIONS.map((option) => (
  254. <option key={option.value} value={option.value}>
  255. {option.label}
  256. </option>
  257. ))}
  258. </select>
  259. <p className="text-xs text-bambu-gray mt-1">
  260. {PROVIDER_OPTIONS.find(o => o.value === providerType)?.description}
  261. </p>
  262. </div>
  263. {/* Provider-specific configuration */}
  264. <div className="space-y-3">
  265. <p className="text-sm text-bambu-gray">Configuration</p>
  266. {configFields.map((field) => (
  267. <div key={field.key}>
  268. <label className="block text-sm text-bambu-gray mb-1">
  269. {field.label} {field.required && '*'}
  270. </label>
  271. {field.type === 'select' && field.options ? (
  272. <select
  273. value={config[field.key] || field.options[0]?.value || ''}
  274. onChange={(e) => {
  275. setConfig({ ...config, [field.key]: e.target.value });
  276. setTestResult(null);
  277. }}
  278. 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"
  279. >
  280. {field.options.map((opt) => (
  281. <option key={opt.value} value={opt.value}>
  282. {opt.label}
  283. </option>
  284. ))}
  285. </select>
  286. ) : (
  287. <input
  288. type={field.type}
  289. value={config[field.key] || ''}
  290. onChange={(e) => {
  291. setConfig({ ...config, [field.key]: e.target.value });
  292. setTestResult(null);
  293. }}
  294. placeholder={field.placeholder}
  295. 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"
  296. />
  297. )}
  298. </div>
  299. ))}
  300. </div>
  301. {/* Test Button */}
  302. <div className="flex gap-2">
  303. <Button
  304. type="button"
  305. variant="secondary"
  306. onClick={() => {
  307. setTestResult(null);
  308. testMutation.mutate();
  309. }}
  310. disabled={testMutation.isPending || !config[getRequiredFields(providerType)[0]?.key]}
  311. className="flex-1"
  312. >
  313. {testMutation.isPending ? (
  314. <Loader2 className="w-4 h-4 animate-spin" />
  315. ) : (
  316. <Send className="w-4 h-4" />
  317. )}
  318. Test Configuration
  319. </Button>
  320. </div>
  321. {/* Test Result */}
  322. {testResult && (
  323. <div className={`p-3 rounded-lg flex items-center gap-2 ${
  324. testResult.success
  325. ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
  326. : 'bg-red-500/20 border border-red-500/50 text-red-400'
  327. }`}>
  328. {testResult.success ? (
  329. <>
  330. <CheckCircle className="w-5 h-5" />
  331. <span>{testResult.message}</span>
  332. </>
  333. ) : (
  334. <>
  335. <XCircle className="w-5 h-5" />
  336. <span>{testResult.message}</span>
  337. </>
  338. )}
  339. </div>
  340. )}
  341. {/* Link to Printer */}
  342. <div>
  343. <label className="block text-sm text-bambu-gray mb-1">Printer Filter</label>
  344. <select
  345. value={printerId ?? ''}
  346. onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
  347. 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"
  348. >
  349. <option value="">All printers</option>
  350. {printers?.map((p) => (
  351. <option key={p.id} value={p.id}>
  352. {p.name}
  353. </option>
  354. ))}
  355. </select>
  356. <p className="text-xs text-bambu-gray mt-1">
  357. Only send notifications for events from this printer
  358. </p>
  359. </div>
  360. {/* Quiet Hours */}
  361. <div className="space-y-2">
  362. <div className="flex items-center justify-between">
  363. <label className="text-sm text-white">Quiet Hours (Do Not Disturb)</label>
  364. <Toggle
  365. checked={quietHoursEnabled}
  366. onChange={setQuietHoursEnabled}
  367. />
  368. </div>
  369. {quietHoursEnabled && (
  370. <div className="grid grid-cols-2 gap-3">
  371. <div>
  372. <label className="block text-xs text-bambu-gray mb-1">Start</label>
  373. <input
  374. type="time"
  375. value={quietHoursStart}
  376. onChange={(e) => setQuietHoursStart(e.target.value)}
  377. 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"
  378. />
  379. </div>
  380. <div>
  381. <label className="block text-xs text-bambu-gray mb-1">End</label>
  382. <input
  383. type="time"
  384. value={quietHoursEnd}
  385. onChange={(e) => setQuietHoursEnd(e.target.value)}
  386. 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"
  387. />
  388. </div>
  389. </div>
  390. )}
  391. </div>
  392. {/* Daily Digest */}
  393. <div className="space-y-2">
  394. <div className="flex items-center justify-between">
  395. <div>
  396. <label className="text-sm text-white">Daily Digest</label>
  397. <p className="text-xs text-bambu-gray">Batch notifications into a single daily summary</p>
  398. </div>
  399. <Toggle
  400. checked={dailyDigestEnabled}
  401. onChange={setDailyDigestEnabled}
  402. />
  403. </div>
  404. {dailyDigestEnabled && (
  405. <div>
  406. <label className="block text-xs text-bambu-gray mb-1">Send digest at</label>
  407. <input
  408. type="time"
  409. value={dailyDigestTime}
  410. onChange={(e) => setDailyDigestTime(e.target.value)}
  411. 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"
  412. />
  413. <p className="text-xs text-bambu-gray mt-1">
  414. Events will be collected and sent as a single summary at this time
  415. </p>
  416. </div>
  417. )}
  418. </div>
  419. {/* Event Toggles */}
  420. <div className="space-y-3">
  421. <p className="text-sm text-bambu-gray">Notification Events</p>
  422. {/* Print Events */}
  423. <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
  424. <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Print Events</p>
  425. <div className="grid grid-cols-2 gap-2">
  426. <div className="flex items-center justify-between">
  427. <span className="text-sm text-white">Start</span>
  428. <Toggle checked={onPrintStart} onChange={setOnPrintStart} />
  429. </div>
  430. <div className="flex items-center justify-between">
  431. <span className="text-sm text-white">Complete</span>
  432. <Toggle checked={onPrintComplete} onChange={setOnPrintComplete} />
  433. </div>
  434. <div className="flex items-center justify-between">
  435. <span className="text-sm text-white">Failed</span>
  436. <Toggle checked={onPrintFailed} onChange={setOnPrintFailed} />
  437. </div>
  438. <div className="flex items-center justify-between">
  439. <span className="text-sm text-white">Stopped</span>
  440. <Toggle checked={onPrintStopped} onChange={setOnPrintStopped} />
  441. </div>
  442. <div className="flex items-center justify-between col-span-2">
  443. <div>
  444. <span className="text-sm text-white">Progress</span>
  445. <span className="text-xs text-bambu-gray ml-1">(25%, 50%, 75%)</span>
  446. </div>
  447. <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
  448. </div>
  449. </div>
  450. </div>
  451. {/* Printer Status Events */}
  452. <div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
  453. <p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">Printer Status</p>
  454. <div className="grid grid-cols-2 gap-2">
  455. <div className="flex items-center justify-between">
  456. <span className="text-sm text-white">Offline</span>
  457. <Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
  458. </div>
  459. <div className="flex items-center justify-between">
  460. <span className="text-sm text-white">Error</span>
  461. <Toggle checked={onPrinterError} onChange={setOnPrinterError} />
  462. </div>
  463. <div className="flex items-center justify-between">
  464. <span className="text-sm text-white">Low Filament</span>
  465. <Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
  466. </div>
  467. <div className="flex items-center justify-between">
  468. <span className="text-sm text-white">Maintenance</span>
  469. <Toggle checked={onMaintenanceDue} onChange={setOnMaintenanceDue} />
  470. </div>
  471. </div>
  472. </div>
  473. </div>
  474. {/* Actions */}
  475. <div className="flex gap-3 pt-2">
  476. <Button
  477. type="button"
  478. variant="secondary"
  479. onClick={onClose}
  480. className="flex-1"
  481. >
  482. Cancel
  483. </Button>
  484. <Button
  485. type="submit"
  486. disabled={isPending}
  487. className="flex-1"
  488. >
  489. {isPending ? (
  490. <Loader2 className="w-4 h-4 animate-spin" />
  491. ) : (
  492. <Save className="w-4 h-4" />
  493. )}
  494. {isEditing ? 'Save' : 'Add'}
  495. </Button>
  496. </div>
  497. </form>
  498. </div>
  499. </div>
  500. );
  501. }