AddSmartPlugModal.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import { useState, useEffect } from 'react';
  2. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  3. import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
  6. import { Button } from './Button';
  7. interface AddSmartPlugModalProps {
  8. plug?: SmartPlug | null;
  9. onClose: () => void;
  10. }
  11. export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
  12. const queryClient = useQueryClient();
  13. const isEditing = !!plug;
  14. const [name, setName] = useState(plug?.name || '');
  15. const [ipAddress, setIpAddress] = useState(plug?.ip_address || '');
  16. const [username, setUsername] = useState(plug?.username || '');
  17. const [password, setPassword] = useState(plug?.password || '');
  18. const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
  19. const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
  20. const [error, setError] = useState<string | null>(null);
  21. // Power alert settings
  22. const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
  23. const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
  24. const [powerAlertLow, setPowerAlertLow] = useState<string>(plug?.power_alert_low?.toString() || '');
  25. // Schedule settings
  26. const [scheduleEnabled, setScheduleEnabled] = useState(plug?.schedule_enabled || false);
  27. const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
  28. const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
  29. // Fetch printers for linking
  30. const { data: printers } = useQuery({
  31. queryKey: ['printers'],
  32. queryFn: api.getPrinters,
  33. });
  34. // Fetch existing plugs to check for conflicts
  35. const { data: existingPlugs } = useQuery({
  36. queryKey: ['smart-plugs'],
  37. queryFn: api.getSmartPlugs,
  38. });
  39. // Close on Escape key
  40. useEffect(() => {
  41. const handleKeyDown = (e: KeyboardEvent) => {
  42. if (e.key === 'Escape') onClose();
  43. };
  44. window.addEventListener('keydown', handleKeyDown);
  45. return () => window.removeEventListener('keydown', handleKeyDown);
  46. }, [onClose]);
  47. // Test connection mutation
  48. const testMutation = useMutation({
  49. mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
  50. onSuccess: (result) => {
  51. setTestResult(result);
  52. setError(null);
  53. // Auto-fill name from device if empty
  54. if (!name && result.device_name) {
  55. setName(result.device_name);
  56. }
  57. },
  58. onError: (err: Error) => {
  59. setTestResult(null);
  60. setError(err.message);
  61. },
  62. });
  63. // Create mutation
  64. const createMutation = useMutation({
  65. mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
  66. onSuccess: () => {
  67. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  68. onClose();
  69. },
  70. onError: (err: Error) => {
  71. setError(err.message);
  72. },
  73. });
  74. // Update mutation
  75. const updateMutation = useMutation({
  76. mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
  77. onSuccess: () => {
  78. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  79. onClose();
  80. },
  81. onError: (err: Error) => {
  82. setError(err.message);
  83. },
  84. });
  85. // Filter out printers that already have a plug assigned (except current plug's printer)
  86. const availablePrinters = printers?.filter(p => {
  87. const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id);
  88. return !hasPlug;
  89. });
  90. const handleSubmit = (e: React.FormEvent) => {
  91. e.preventDefault();
  92. setError(null);
  93. if (!name.trim()) {
  94. setError('Name is required');
  95. return;
  96. }
  97. if (!ipAddress.trim()) {
  98. setError('IP address is required');
  99. return;
  100. }
  101. const data = {
  102. name: name.trim(),
  103. ip_address: ipAddress.trim(),
  104. username: username.trim() || null,
  105. password: password.trim() || null,
  106. printer_id: printerId,
  107. // Power alerts
  108. power_alert_enabled: powerAlertEnabled,
  109. power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
  110. power_alert_low: powerAlertLow ? parseFloat(powerAlertLow) : null,
  111. // Schedule
  112. schedule_enabled: scheduleEnabled,
  113. schedule_on_time: scheduleOnTime || null,
  114. schedule_off_time: scheduleOffTime || null,
  115. };
  116. if (isEditing) {
  117. updateMutation.mutate(data);
  118. } else {
  119. createMutation.mutate(data);
  120. }
  121. };
  122. const isPending = createMutation.isPending || updateMutation.isPending;
  123. return (
  124. <div
  125. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  126. onClick={onClose}
  127. >
  128. <div
  129. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md"
  130. onClick={(e) => e.stopPropagation()}
  131. >
  132. {/* Header */}
  133. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  134. <h2 className="text-lg font-semibold text-white">
  135. {isEditing ? 'Edit Smart Plug' : 'Add Smart Plug'}
  136. </h2>
  137. <button
  138. onClick={onClose}
  139. className="text-bambu-gray hover:text-white transition-colors"
  140. >
  141. <X className="w-5 h-5" />
  142. </button>
  143. </div>
  144. {/* Form */}
  145. <form onSubmit={handleSubmit} className="p-6 space-y-4">
  146. {error && (
  147. <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
  148. {error}
  149. </div>
  150. )}
  151. {/* IP Address */}
  152. <div>
  153. <label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
  154. <div className="flex gap-2">
  155. <input
  156. type="text"
  157. value={ipAddress}
  158. onChange={(e) => {
  159. setIpAddress(e.target.value);
  160. setTestResult(null);
  161. }}
  162. placeholder="192.168.1.100"
  163. className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
  164. />
  165. <Button
  166. type="button"
  167. variant="secondary"
  168. onClick={() => testMutation.mutate()}
  169. disabled={!ipAddress.trim() || testMutation.isPending}
  170. >
  171. {testMutation.isPending ? (
  172. <Loader2 className="w-4 h-4 animate-spin" />
  173. ) : (
  174. <Wifi className="w-4 h-4" />
  175. )}
  176. Test
  177. </Button>
  178. </div>
  179. </div>
  180. {/* Test Result */}
  181. {testResult && (
  182. <div className={`p-3 rounded-lg flex items-center gap-2 ${
  183. testResult.success
  184. ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
  185. : 'bg-red-500/20 border border-red-500/50 text-red-400'
  186. }`}>
  187. {testResult.success ? (
  188. <>
  189. <CheckCircle className="w-5 h-5" />
  190. <div>
  191. <p className="font-medium">Connected!</p>
  192. <p className="text-sm opacity-80">
  193. {testResult.device_name && `Device: ${testResult.device_name} - `}
  194. State: {testResult.state}
  195. </p>
  196. </div>
  197. </>
  198. ) : (
  199. <>
  200. <WifiOff className="w-5 h-5" />
  201. <span>Connection failed</span>
  202. </>
  203. )}
  204. </div>
  205. )}
  206. {/* Name */}
  207. <div>
  208. <label className="block text-sm text-bambu-gray mb-1">Name *</label>
  209. <input
  210. type="text"
  211. value={name}
  212. onChange={(e) => setName(e.target.value)}
  213. placeholder="Living Room Plug"
  214. 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"
  215. />
  216. </div>
  217. {/* Authentication (optional) */}
  218. <div className="grid grid-cols-2 gap-3">
  219. <div>
  220. <label className="block text-sm text-bambu-gray mb-1">Username</label>
  221. <input
  222. type="text"
  223. value={username}
  224. onChange={(e) => setUsername(e.target.value)}
  225. placeholder="admin"
  226. 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"
  227. />
  228. </div>
  229. <div>
  230. <label className="block text-sm text-bambu-gray mb-1">Password</label>
  231. <input
  232. type="password"
  233. value={password}
  234. onChange={(e) => setPassword(e.target.value)}
  235. placeholder="********"
  236. 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"
  237. />
  238. </div>
  239. </div>
  240. <p className="text-xs text-bambu-gray -mt-2">
  241. Leave empty if your Tasmota device doesn't require authentication
  242. </p>
  243. {/* Link to Printer */}
  244. <div>
  245. <label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
  246. <select
  247. value={printerId ?? ''}
  248. onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
  249. 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"
  250. >
  251. <option value="">No printer (manual control only)</option>
  252. {availablePrinters?.map((p) => (
  253. <option key={p.id} value={p.id}>
  254. {p.name}
  255. </option>
  256. ))}
  257. </select>
  258. <p className="text-xs text-bambu-gray mt-1">
  259. Linking enables automatic on/off when prints start/complete
  260. </p>
  261. </div>
  262. {/* Power Alerts */}
  263. <div className="border-t border-bambu-dark-tertiary pt-4">
  264. <div className="flex items-center justify-between mb-3">
  265. <div className="flex items-center gap-2">
  266. <Bell className="w-4 h-4 text-bambu-green" />
  267. <span className="text-white font-medium">Power Alerts</span>
  268. </div>
  269. <label className="relative inline-flex items-center cursor-pointer">
  270. <input
  271. type="checkbox"
  272. checked={powerAlertEnabled}
  273. onChange={(e) => setPowerAlertEnabled(e.target.checked)}
  274. className="sr-only peer"
  275. />
  276. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  277. </label>
  278. </div>
  279. {powerAlertEnabled && (
  280. <div className="space-y-3">
  281. <div className="grid grid-cols-2 gap-3">
  282. <div>
  283. <label className="block text-sm text-bambu-gray mb-1">Alert if above (W)</label>
  284. <input
  285. type="number"
  286. value={powerAlertHigh}
  287. onChange={(e) => setPowerAlertHigh(e.target.value)}
  288. placeholder="e.g. 200"
  289. min="0"
  290. max="5000"
  291. 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"
  292. />
  293. </div>
  294. <div>
  295. <label className="block text-sm text-bambu-gray mb-1">Alert if below (W)</label>
  296. <input
  297. type="number"
  298. value={powerAlertLow}
  299. onChange={(e) => setPowerAlertLow(e.target.value)}
  300. placeholder="e.g. 10"
  301. min="0"
  302. max="5000"
  303. 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"
  304. />
  305. </div>
  306. </div>
  307. <p className="text-xs text-bambu-gray">
  308. Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.
  309. </p>
  310. </div>
  311. )}
  312. </div>
  313. {/* Schedule */}
  314. <div className="border-t border-bambu-dark-tertiary pt-4">
  315. <div className="flex items-center justify-between mb-3">
  316. <div className="flex items-center gap-2">
  317. <Clock className="w-4 h-4 text-bambu-green" />
  318. <span className="text-white font-medium">Daily Schedule</span>
  319. </div>
  320. <label className="relative inline-flex items-center cursor-pointer">
  321. <input
  322. type="checkbox"
  323. checked={scheduleEnabled}
  324. onChange={(e) => setScheduleEnabled(e.target.checked)}
  325. className="sr-only peer"
  326. />
  327. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  328. </label>
  329. </div>
  330. {scheduleEnabled && (
  331. <div className="space-y-3">
  332. <div className="grid grid-cols-2 gap-3">
  333. <div>
  334. <label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
  335. <input
  336. type="time"
  337. value={scheduleOnTime}
  338. onChange={(e) => setScheduleOnTime(e.target.value)}
  339. 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"
  340. />
  341. </div>
  342. <div>
  343. <label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
  344. <input
  345. type="time"
  346. value={scheduleOffTime}
  347. onChange={(e) => setScheduleOffTime(e.target.value)}
  348. 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"
  349. />
  350. </div>
  351. </div>
  352. <p className="text-xs text-bambu-gray">
  353. Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
  354. </p>
  355. </div>
  356. )}
  357. </div>
  358. {/* Actions */}
  359. <div className="flex gap-3 pt-2">
  360. <Button
  361. type="button"
  362. variant="secondary"
  363. onClick={onClose}
  364. className="flex-1"
  365. >
  366. Cancel
  367. </Button>
  368. <Button
  369. type="submit"
  370. disabled={isPending}
  371. className="flex-1"
  372. >
  373. {isPending ? (
  374. <Loader2 className="w-4 h-4 animate-spin" />
  375. ) : (
  376. <Save className="w-4 h-4" />
  377. )}
  378. {isEditing ? 'Save' : 'Add'}
  379. </Button>
  380. </div>
  381. </form>
  382. </div>
  383. </div>
  384. );
  385. }