SmartPlugCard.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import { useState } from 'react';
  2. import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
  3. import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Play, Eye } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import type { SmartPlug, SmartPlugUpdate } from '../api/client';
  6. import { Card, CardContent } from './Card';
  7. import { Button } from './Button';
  8. import { ConfirmModal } from './ConfirmModal';
  9. import { useToast } from '../contexts/ToastContext';
  10. interface SmartPlugCardProps {
  11. plug: SmartPlug;
  12. onEdit: (plug: SmartPlug) => void;
  13. }
  14. export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
  15. const queryClient = useQueryClient();
  16. const { showToast } = useToast();
  17. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  18. const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
  19. const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
  20. const [isExpanded, setIsExpanded] = useState(false);
  21. // Fetch current status
  22. const { data: status, isLoading: statusLoading } = useQuery({
  23. queryKey: ['smart-plug-status', plug.id],
  24. queryFn: () => api.getSmartPlugStatus(plug.id),
  25. refetchInterval: 30000, // Refresh every 30 seconds
  26. });
  27. // Fetch printers for linking
  28. const { data: printers } = useQuery({
  29. queryKey: ['printers'],
  30. queryFn: api.getPrinters,
  31. });
  32. const linkedPrinter = printers?.find(p => p.id === plug.printer_id);
  33. // Control mutation with optimistic updates
  34. const controlMutation = useMutation({
  35. mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action),
  36. onMutate: async (action) => {
  37. // Cancel any outgoing refetches
  38. await queryClient.cancelQueries({ queryKey: ['smart-plug-status', plug.id] });
  39. // Snapshot the previous value
  40. const previousStatus = queryClient.getQueryData(['smart-plug-status', plug.id]);
  41. // Optimistically update to the new value
  42. const newState = action === 'on' ? 'ON' : action === 'off' ? 'OFF' : (status?.state === 'ON' ? 'OFF' : 'ON');
  43. queryClient.setQueryData(['smart-plug-status', plug.id], (old: typeof status) => ({
  44. ...old,
  45. state: newState,
  46. }));
  47. return { previousStatus };
  48. },
  49. onSuccess: (_data, action) => {
  50. // Show toast for script triggers
  51. const isScriptPlug = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.');
  52. if (isScriptPlug && action === 'on') {
  53. showToast(`Script "${plug.name}" triggered`, 'success');
  54. }
  55. },
  56. onError: (_err, action, context) => {
  57. // Rollback on error
  58. if (context?.previousStatus) {
  59. queryClient.setQueryData(['smart-plug-status', plug.id], context.previousStatus);
  60. }
  61. const isScriptPlug = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.');
  62. if (isScriptPlug) {
  63. showToast(`Failed to trigger script "${plug.name}"`, 'error');
  64. } else {
  65. showToast(`Failed to turn ${action} "${plug.name}"`, 'error');
  66. }
  67. },
  68. onSettled: () => {
  69. // Refetch after a short delay to get actual state
  70. setTimeout(() => {
  71. queryClient.invalidateQueries({ queryKey: ['smart-plug-status', plug.id] });
  72. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  73. }, 1000);
  74. },
  75. });
  76. // Update mutation
  77. const updateMutation = useMutation({
  78. mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug.id, data),
  79. onSuccess: () => {
  80. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  81. // Also invalidate printer-specific smart plug queries to keep PrintersPage in sync
  82. if (plug.printer_id) {
  83. queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
  84. }
  85. // Invalidate script plugs queries for printer cards
  86. queryClient.invalidateQueries({ predicate: (query) =>
  87. Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter'
  88. });
  89. },
  90. });
  91. // Delete mutation
  92. const deleteMutation = useMutation({
  93. mutationFn: () => api.deleteSmartPlug(plug.id),
  94. onSuccess: () => {
  95. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  96. // Also invalidate script plugs queries for printer cards
  97. queryClient.invalidateQueries({ predicate: (query) =>
  98. Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter'
  99. });
  100. },
  101. });
  102. const isOn = status?.state === 'ON';
  103. const isReachable = status?.reachable ?? false;
  104. const isPending = controlMutation.isPending;
  105. // Check if this is a HA script entity (scripts can only be triggered, not toggled)
  106. const isScript = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.');
  107. // Generate admin URL with auto-login credentials (Tasmota only)
  108. const getAdminUrl = () => {
  109. if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;
  110. const ip = plug.ip_address;
  111. if (plug.username && plug.password) {
  112. // Use HTTP Basic Auth in URL for auto-login
  113. return `http://${encodeURIComponent(plug.username)}:${encodeURIComponent(plug.password)}@${ip}/`;
  114. }
  115. return `http://${ip}/`;
  116. };
  117. const adminUrl = getAdminUrl();
  118. return (
  119. <>
  120. <Card className="relative">
  121. <CardContent className="p-4">
  122. {/* Header Row */}
  123. <div className="flex items-start justify-between gap-2 mb-3">
  124. <div className="flex items-center gap-3 min-w-0 flex-1">
  125. <div className={`p-2 rounded-lg flex-shrink-0 ${isReachable ? ((isOn || isScript) ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
  126. {isScript ? (
  127. <Play className={`w-5 h-5 ${isReachable ? 'text-bambu-green' : 'text-red-400'}`} />
  128. ) : plug.plug_type === 'homeassistant' ? (
  129. <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
  130. ) : (
  131. <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
  132. )}
  133. </div>
  134. <div className="min-w-0">
  135. <h3 className="font-medium text-white truncate" title={plug.name}>{plug.name}</h3>
  136. <p className="text-sm text-bambu-gray truncate" title={plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.ip_address ?? undefined}>
  137. {plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
  138. </p>
  139. </div>
  140. </div>
  141. {/* Status indicator */}
  142. <div className="flex flex-col items-end gap-1 flex-shrink-0">
  143. {statusLoading ? (
  144. <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
  145. ) : isScript ? (
  146. /* Script entities: show badge and Ready status stacked */
  147. <div className="flex flex-col items-end gap-1">
  148. <span className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded-full">
  149. <Play className="w-3 h-3" />
  150. Script
  151. </span>
  152. <span className={`text-sm ${isReachable ? 'text-status-ok' : 'text-status-error'}`}>
  153. {isReachable ? 'Ready' : 'Offline'}
  154. </span>
  155. </div>
  156. ) : isReachable ? (
  157. <div className="flex items-center gap-1 text-sm">
  158. <Wifi className="w-4 h-4 text-status-ok" />
  159. <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>
  160. {status?.state || 'Unknown'}
  161. </span>
  162. </div>
  163. ) : (
  164. <div className="flex items-center gap-1 text-sm text-status-error">
  165. <WifiOff className="w-4 h-4" />
  166. <span>Offline</span>
  167. </div>
  168. )}
  169. {/* Admin page link - only for Tasmota */}
  170. {adminUrl && (
  171. <a
  172. href={adminUrl}
  173. target="_blank"
  174. rel="noopener noreferrer"
  175. className="flex items-center gap-1 px-2 py-0.5 bg-bambu-dark hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white text-xs rounded-full transition-colors"
  176. title="Open plug admin page"
  177. >
  178. <ExternalLink className="w-3 h-3" />
  179. Admin
  180. </a>
  181. )}
  182. </div>
  183. </div>
  184. {/* Linked Printer */}
  185. {linkedPrinter && (
  186. <div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
  187. <span className="text-xs text-bambu-gray">Linked to: </span>
  188. <span className="text-sm text-white">{linkedPrinter.name}</span>
  189. </div>
  190. )}
  191. {/* Feature Badges */}
  192. {(plug.power_alert_enabled || plug.schedule_enabled) && (
  193. <div className="flex flex-wrap gap-1.5 mb-3">
  194. {plug.power_alert_enabled && (
  195. <span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
  196. <Bell className="w-3 h-3" />
  197. Alerts
  198. </span>
  199. )}
  200. {plug.schedule_enabled && (
  201. <span className="flex items-center gap-1 px-2 py-0.5 bg-blue-500/20 text-blue-400 text-xs rounded-full">
  202. <Calendar className="w-3 h-3" />
  203. {plug.schedule_on_time && plug.schedule_off_time
  204. ? `${plug.schedule_on_time} - ${plug.schedule_off_time}`
  205. : plug.schedule_on_time
  206. ? `On ${plug.schedule_on_time}`
  207. : `Off ${plug.schedule_off_time}`}
  208. </span>
  209. )}
  210. </div>
  211. )}
  212. {/* Quick Controls */}
  213. <div className="flex gap-2 mb-3">
  214. {isScript ? (
  215. /* Script entities: single "Run" button */
  216. <Button
  217. size="sm"
  218. variant="primary"
  219. disabled={!isReachable || isPending}
  220. onClick={() => setShowPowerOnConfirm(true)}
  221. className="flex-1"
  222. >
  223. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
  224. Run Script
  225. </Button>
  226. ) : (
  227. /* Regular entities: On/Off buttons */
  228. <>
  229. <Button
  230. size="sm"
  231. variant={isOn ? 'primary' : 'secondary'}
  232. disabled={!isReachable || isPending}
  233. onClick={() => setShowPowerOnConfirm(true)}
  234. className="flex-1"
  235. >
  236. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
  237. On
  238. </Button>
  239. <Button
  240. size="sm"
  241. variant={!isOn ? 'primary' : 'secondary'}
  242. disabled={!isReachable || isPending}
  243. onClick={() => setShowPowerOffConfirm(true)}
  244. className="flex-1"
  245. >
  246. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
  247. Off
  248. </Button>
  249. </>
  250. )}
  251. </div>
  252. {/* Toggle Settings Panel */}
  253. <button
  254. onClick={() => setIsExpanded(!isExpanded)}
  255. className="w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors"
  256. >
  257. <span className="flex items-center gap-2">
  258. <Settings2 className="w-4 h-4" />
  259. Automation Settings
  260. </span>
  261. <span>{isExpanded ? '-' : '+'}</span>
  262. </button>
  263. {/* Expanded Settings */}
  264. {isExpanded && (
  265. <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
  266. {/* Show on Printer Card Toggle - only for scripts */}
  267. {isScript && (
  268. <div className="flex items-center justify-between">
  269. <div className="flex items-center gap-2">
  270. <Eye className="w-4 h-4 text-bambu-green" />
  271. <div>
  272. <p className="text-sm text-white">Show on Printer Card</p>
  273. <p className="text-xs text-bambu-gray">Display script button on printer card</p>
  274. </div>
  275. </div>
  276. <label className="relative inline-flex items-center cursor-pointer">
  277. <input
  278. type="checkbox"
  279. checked={plug.show_on_printer_card}
  280. onChange={(e) => updateMutation.mutate({ show_on_printer_card: e.target.checked })}
  281. className="sr-only peer"
  282. />
  283. <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
  284. </label>
  285. </div>
  286. )}
  287. {/* Show in Switchbar Toggle */}
  288. <div className="flex items-center justify-between">
  289. <div className="flex items-center gap-2">
  290. <LayoutGrid className="w-4 h-4 text-bambu-green" />
  291. <div>
  292. <p className="text-sm text-white">Show in Switchbar</p>
  293. <p className="text-xs text-bambu-gray">Quick access from sidebar</p>
  294. </div>
  295. </div>
  296. <label className="relative inline-flex items-center cursor-pointer">
  297. <input
  298. type="checkbox"
  299. checked={plug.show_in_switchbar}
  300. onChange={(e) => updateMutation.mutate({ show_in_switchbar: e.target.checked })}
  301. className="sr-only peer"
  302. />
  303. <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
  304. </label>
  305. </div>
  306. {/* Enabled Toggle */}
  307. <div className="flex items-center justify-between">
  308. <div>
  309. <p className="text-sm text-white">Enabled</p>
  310. <p className="text-xs text-bambu-gray">Enable automation for this plug</p>
  311. </div>
  312. <label className="relative inline-flex items-center cursor-pointer">
  313. <input
  314. type="checkbox"
  315. checked={plug.enabled}
  316. onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
  317. className="sr-only peer"
  318. />
  319. <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
  320. </label>
  321. </div>
  322. {/* Auto On / Run when printer turns on */}
  323. <div className="flex items-center justify-between">
  324. <div>
  325. <p className="text-sm text-white">{isScript ? 'Run when printer turns on' : 'Auto On'}</p>
  326. <p className="text-xs text-bambu-gray">
  327. {isScript ? 'Execute script when main plug is switched on' : 'Turn on when print starts'}
  328. </p>
  329. </div>
  330. <label className="relative inline-flex items-center cursor-pointer">
  331. <input
  332. type="checkbox"
  333. checked={plug.auto_on}
  334. onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
  335. className="sr-only peer"
  336. />
  337. <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
  338. </label>
  339. </div>
  340. {/* Auto Off / Run when printer turns off */}
  341. <div className="flex items-center justify-between">
  342. <div>
  343. <p className="text-sm text-white">{isScript ? 'Run when printer turns off' : 'Auto Off'}</p>
  344. <p className="text-xs text-bambu-gray">
  345. {isScript ? 'Execute script when main plug is switched off' : 'Turn off when print completes (one-shot)'}
  346. </p>
  347. </div>
  348. <label className="relative inline-flex items-center cursor-pointer">
  349. <input
  350. type="checkbox"
  351. checked={plug.auto_off}
  352. onChange={(e) => updateMutation.mutate({ auto_off: e.target.checked })}
  353. className="sr-only peer"
  354. />
  355. <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
  356. </label>
  357. </div>
  358. {/* Delay Mode - hidden for script entities */}
  359. {plug.auto_off && !isScript && (
  360. <div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
  361. <div>
  362. <p className="text-sm text-white mb-2">Turn Off Delay Mode</p>
  363. <div className="flex gap-2">
  364. <button
  365. onClick={() => updateMutation.mutate({ off_delay_mode: 'time' })}
  366. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
  367. plug.off_delay_mode === 'time'
  368. ? 'bg-bambu-green text-white'
  369. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  370. }`}
  371. >
  372. <Clock className="w-4 h-4" />
  373. Time
  374. </button>
  375. <button
  376. onClick={() => updateMutation.mutate({ off_delay_mode: 'temperature' })}
  377. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
  378. plug.off_delay_mode === 'temperature'
  379. ? 'bg-bambu-green text-white'
  380. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  381. }`}
  382. >
  383. <Thermometer className="w-4 h-4" />
  384. Temp
  385. </button>
  386. </div>
  387. </div>
  388. {plug.off_delay_mode === 'time' ? (
  389. <div>
  390. <label className="block text-xs text-bambu-gray mb-1">Delay (minutes)</label>
  391. <input
  392. type="number"
  393. min="1"
  394. max="60"
  395. value={plug.off_delay_minutes}
  396. onChange={(e) => updateMutation.mutate({ off_delay_minutes: parseInt(e.target.value) || 5 })}
  397. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  398. />
  399. </div>
  400. ) : (
  401. <div>
  402. <label className="block text-xs text-bambu-gray mb-1">Temperature threshold (C)</label>
  403. <input
  404. type="number"
  405. min="30"
  406. max="100"
  407. value={plug.off_temp_threshold}
  408. onChange={(e) => updateMutation.mutate({ off_temp_threshold: parseInt(e.target.value) || 70 })}
  409. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
  410. />
  411. <p className="text-xs text-bambu-gray mt-1">Turns off when nozzle cools below this temperature</p>
  412. </div>
  413. )}
  414. </div>
  415. )}
  416. {/* Action Buttons */}
  417. <div className="flex gap-2 pt-2">
  418. <Button
  419. size="sm"
  420. variant="secondary"
  421. onClick={() => onEdit(plug)}
  422. className="flex-1"
  423. >
  424. <Edit2 className="w-4 h-4" />
  425. Edit
  426. </Button>
  427. <Button
  428. size="sm"
  429. variant="secondary"
  430. onClick={() => setShowDeleteConfirm(true)}
  431. className="text-red-400 hover:text-red-300"
  432. >
  433. <Trash2 className="w-4 h-4" />
  434. </Button>
  435. </div>
  436. </div>
  437. )}
  438. </CardContent>
  439. </Card>
  440. {/* Delete Confirmation */}
  441. {showDeleteConfirm && (
  442. <ConfirmModal
  443. title="Delete Smart Plug"
  444. message={`Are you sure you want to delete "${plug.name}"? This cannot be undone.`}
  445. confirmText="Delete"
  446. variant="danger"
  447. onConfirm={() => {
  448. deleteMutation.mutate();
  449. setShowDeleteConfirm(false);
  450. }}
  451. onCancel={() => setShowDeleteConfirm(false)}
  452. />
  453. )}
  454. {/* Power On / Run Script Confirmation */}
  455. {showPowerOnConfirm && (
  456. <ConfirmModal
  457. title={isScript ? "Run Script" : "Turn On Smart Plug"}
  458. message={isScript
  459. ? `Are you sure you want to run the script "${plug.name}"?`
  460. : `Are you sure you want to turn on "${plug.name}"?`}
  461. confirmText={isScript ? "Run" : "Turn On"}
  462. variant="default"
  463. onConfirm={() => {
  464. controlMutation.mutate('on');
  465. setShowPowerOnConfirm(false);
  466. }}
  467. onCancel={() => setShowPowerOnConfirm(false)}
  468. />
  469. )}
  470. {/* Power Off Confirmation */}
  471. {showPowerOffConfirm && (
  472. <ConfirmModal
  473. title="Turn Off Smart Plug"
  474. message={`Are you sure you want to turn off "${plug.name}"? This will cut power to the connected device.`}
  475. confirmText="Turn Off"
  476. variant="danger"
  477. onConfirm={() => {
  478. controlMutation.mutate('off');
  479. setShowPowerOffConfirm(false);
  480. }}
  481. onCancel={() => setShowPowerOffConfirm(false)}
  482. />
  483. )}
  484. </>
  485. );
  486. }