SmartPlugCard.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  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, Radio, Eye, Globe } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../api/client';
  6. import type { SmartPlug, SmartPlugUpdate } from '../api/client';
  7. import { Card, CardContent } from './Card';
  8. import { Button } from './Button';
  9. import { ConfirmModal } from './ConfirmModal';
  10. import { useToast } from '../contexts/ToastContext';
  11. import { NumberInput } from './NumberInput';
  12. interface SmartPlugCardProps {
  13. plug: SmartPlug;
  14. onEdit: (plug: SmartPlug) => void;
  15. }
  16. export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
  17. const { t } = useTranslation();
  18. const queryClient = useQueryClient();
  19. const { showToast } = useToast();
  20. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  21. const [showPowerOnConfirm, setShowPowerOnConfirm] = useState(false);
  22. const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
  23. const [isExpanded, setIsExpanded] = useState(false);
  24. // Fetch current status
  25. const { data: status, isLoading: statusLoading } = useQuery({
  26. queryKey: ['smart-plug-status', plug.id],
  27. queryFn: () => api.getSmartPlugStatus(plug.id),
  28. refetchInterval: 30000, // Refresh every 30 seconds
  29. });
  30. // Fetch printers for linking
  31. const { data: printers } = useQuery({
  32. queryKey: ['printers'],
  33. queryFn: api.getPrinters,
  34. });
  35. const linkedPrinter = printers?.find(p => p.id === plug.printer_id);
  36. // Control mutation with optimistic updates
  37. const controlMutation = useMutation({
  38. mutationFn: (action: 'on' | 'off' | 'toggle') => api.controlSmartPlug(plug.id, action),
  39. onMutate: async (action) => {
  40. // Cancel any outgoing refetches
  41. await queryClient.cancelQueries({ queryKey: ['smart-plug-status', plug.id] });
  42. // Snapshot the previous value
  43. const previousStatus = queryClient.getQueryData(['smart-plug-status', plug.id]);
  44. // Optimistically update to the new value
  45. const newState = action === 'on' ? 'ON' : action === 'off' ? 'OFF' : (status?.state === 'ON' ? 'OFF' : 'ON');
  46. queryClient.setQueryData(['smart-plug-status', plug.id], (old: typeof status) => ({
  47. ...old,
  48. state: newState,
  49. }));
  50. return { previousStatus };
  51. },
  52. onError: (_err, action, context) => {
  53. // Rollback on error
  54. if (context?.previousStatus) {
  55. queryClient.setQueryData(['smart-plug-status', plug.id], context.previousStatus);
  56. }
  57. showToast(t('smartPlugs.failedToTurn', { action, name: plug.name }), 'error');
  58. },
  59. onSettled: () => {
  60. // Refetch after a short delay to get actual state
  61. setTimeout(() => {
  62. queryClient.invalidateQueries({ queryKey: ['smart-plug-status', plug.id] });
  63. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  64. }, 1000);
  65. },
  66. });
  67. // Update mutation
  68. const updateMutation = useMutation({
  69. mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug.id, data),
  70. onSuccess: () => {
  71. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  72. // Also invalidate printer-specific smart plug queries to keep PrintersPage in sync
  73. if (plug.printer_id) {
  74. queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
  75. queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
  76. }
  77. },
  78. });
  79. // Delete mutation
  80. const deleteMutation = useMutation({
  81. mutationFn: () => api.deleteSmartPlug(plug.id),
  82. onSuccess: () => {
  83. queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
  84. // Also invalidate printer card HA entity queries
  85. if (plug.printer_id) {
  86. queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
  87. }
  88. },
  89. });
  90. const isOn = status?.state === 'ON';
  91. // For MQTT plugs, consider reachable if we have power data (even if backend says not reachable)
  92. const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
  93. const isReachable = (status?.reachable ?? false) || hasMqttData;
  94. const isPending = controlMutation.isPending;
  95. // Generate admin URL with auto-login credentials (Tasmota only)
  96. const getAdminUrl = () => {
  97. if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null;
  98. const ip = plug.ip_address;
  99. if (plug.username && plug.password) {
  100. // Use HTTP Basic Auth in URL for auto-login
  101. return `http://${encodeURIComponent(plug.username)}:${encodeURIComponent(plug.password)}@${ip}/`;
  102. }
  103. return `http://${ip}/`;
  104. };
  105. const adminUrl = getAdminUrl();
  106. return (
  107. <>
  108. <Card className="relative">
  109. <CardContent className="p-4">
  110. {/* Header Row */}
  111. <div className="flex items-start justify-between gap-2 mb-3">
  112. <div className="flex items-center gap-3 min-w-0 flex-1">
  113. <div className={`p-2 rounded-lg flex-shrink-0 ${
  114. plug.plug_type === 'mqtt'
  115. ? (isReachable ? 'bg-teal-100 dark:bg-teal-500/20' : 'bg-red-100 dark:bg-red-500/20')
  116. : (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-100 dark:bg-red-500/20')
  117. }`}>
  118. {plug.plug_type === 'mqtt' ? (
  119. <Radio className={`w-5 h-5 ${isReachable ? 'text-teal-600 dark:text-teal-400' : 'text-red-600 dark:text-red-400'}`} />
  120. ) : plug.plug_type === 'homeassistant' ? (
  121. <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-600 dark:text-red-400'}`} />
  122. ) : plug.plug_type === 'rest' ? (
  123. <Globe className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-600 dark:text-red-400'}`} />
  124. ) : (
  125. <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-600 dark:text-red-400'}`} />
  126. )}
  127. </div>
  128. <div className="min-w-0">
  129. <h3 className="font-medium text-white truncate">{plug.name}</h3>
  130. <p
  131. className="text-sm text-bambu-gray truncate"
  132. title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.plug_type === 'rest' ? plug.rest_on_url ?? plug.rest_off_url ?? undefined : plug.ip_address ?? undefined}
  133. >
  134. {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.plug_type === 'rest' ? (plug.rest_on_url || plug.rest_off_url) : plug.ip_address}
  135. </p>
  136. </div>
  137. </div>
  138. {/* Status indicator */}
  139. <div className="flex flex-col items-end gap-1 flex-shrink-0">
  140. {statusLoading ? (
  141. <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
  142. ) : plug.plug_type === 'mqtt' ? (
  143. /* MQTT plugs - show badge and checkmark when receiving data */
  144. <div className="flex items-center gap-1.5 text-sm whitespace-nowrap">
  145. <span className="px-1.5 py-0.5 bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400 text-[10px] font-medium rounded flex-shrink-0">MQTT</span>
  146. {isReachable && <span className="text-status-ok">✓</span>}
  147. </div>
  148. ) : plug.plug_type === 'homeassistant' ? (
  149. <div className="flex items-center gap-1 text-sm">
  150. <span className="px-1 py-0.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 text-[10px] font-medium rounded">HA</span>
  151. <span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
  152. {isReachable ? (status?.state || '?') : t('smartPlugs.offline')}
  153. </span>
  154. </div>
  155. ) : plug.plug_type === 'rest' ? (
  156. <div className="flex items-center gap-1 text-sm">
  157. <span className="px-1 py-0.5 bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-400 text-[10px] font-medium rounded">REST</span>
  158. <span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
  159. {isReachable ? (status?.state || '?') : t('smartPlugs.offline')}
  160. </span>
  161. </div>
  162. ) : isReachable ? (
  163. <div className="flex items-center gap-1 text-sm">
  164. <Wifi className="w-4 h-4 text-status-ok" />
  165. <span className={isOn ? 'text-status-ok' : 'text-bambu-gray'}>{status?.state || t('smartPlugs.unknown')}</span>
  166. </div>
  167. ) : (
  168. <div className="flex items-center gap-1 text-sm text-status-error">
  169. <WifiOff className="w-4 h-4" />
  170. <span>{t('smartPlugs.offline')}</span>
  171. </div>
  172. )}
  173. {/* Admin page link - only for Tasmota */}
  174. {adminUrl && (
  175. <a
  176. href={adminUrl}
  177. target="_blank"
  178. rel="noopener noreferrer"
  179. 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"
  180. title={t('smartPlugs.openPlugAdminPage')}
  181. >
  182. <ExternalLink className="w-3 h-3" />
  183. {t('smartPlugs.admin')}
  184. </a>
  185. )}
  186. </div>
  187. </div>
  188. {/* Linked Printer */}
  189. {linkedPrinter && (
  190. <div className="mb-3 px-2 py-1.5 bg-bambu-dark rounded-lg">
  191. <span className="text-xs text-bambu-gray">{t('smartPlugs.linkedTo')} </span>
  192. <span className="text-sm text-white">{linkedPrinter.name}</span>
  193. </div>
  194. )}
  195. {/* Feature Badges */}
  196. {(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
  197. <div className="flex flex-wrap gap-1.5 mb-3">
  198. {plug.plug_type === 'mqtt' && (
  199. <span className="flex items-center gap-1 px-2 py-0.5 bg-teal-100 dark:bg-teal-500/20 text-teal-700 dark:text-teal-400 text-xs rounded-full">
  200. <Eye className="w-3 h-3" />
  201. {t('smartPlugs.monitorOnly')}
  202. </span>
  203. )}
  204. {plug.power_alert_enabled && (
  205. <span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 text-xs rounded-full">
  206. <Bell className="w-3 h-3" />
  207. {t('smartPlugs.alerts')}
  208. </span>
  209. )}
  210. {plug.schedule_enabled && (
  211. <span className="flex items-center gap-1 px-2 py-0.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 text-xs rounded-full">
  212. <Calendar className="w-3 h-3" />
  213. {plug.schedule_on_time && plug.schedule_off_time
  214. ? `${plug.schedule_on_time} - ${plug.schedule_off_time}`
  215. : plug.schedule_on_time
  216. ? t('smartPlugs.scheduleOn', { time: plug.schedule_on_time })
  217. : t('smartPlugs.scheduleOff', { time: plug.schedule_off_time })}
  218. </span>
  219. )}
  220. </div>
  221. )}
  222. {/* Quick Controls - hidden for MQTT plugs (monitor-only) */}
  223. {plug.plug_type !== 'mqtt' && (
  224. <div className="flex gap-2 mb-3">
  225. <Button
  226. size="sm"
  227. variant={isOn ? 'primary' : 'secondary'}
  228. disabled={!isReachable || isPending}
  229. onClick={() => setShowPowerOnConfirm(true)}
  230. className="flex-1"
  231. >
  232. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
  233. {t('smartPlugs.on')}
  234. </Button>
  235. <Button
  236. size="sm"
  237. variant={!isOn ? 'primary' : 'secondary'}
  238. disabled={!isReachable || isPending}
  239. onClick={() => setShowPowerOffConfirm(true)}
  240. className="flex-1"
  241. >
  242. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
  243. {t('smartPlugs.off')}
  244. </Button>
  245. </div>
  246. )}
  247. {/* Energy display for MQTT plugs */}
  248. {plug.plug_type === 'mqtt' && status?.energy && (
  249. <div className="flex gap-2 mb-3 px-3 py-2 bg-bambu-dark rounded-lg">
  250. {status.energy.power !== null && status.energy.power !== undefined && (
  251. <div className="flex-1 text-center">
  252. <p className="text-lg font-semibold text-white">{Math.round(status.energy.power)}W</p>
  253. <p className="text-xs text-bambu-gray">{t('smartPlugs.power')}</p>
  254. </div>
  255. )}
  256. {status.energy.today !== null && status.energy.today !== undefined && (
  257. <div className="flex-1 text-center border-l border-bambu-dark-tertiary">
  258. <p className="text-lg font-semibold text-white">{status.energy.today.toFixed(3)}</p>
  259. <p className="text-xs text-bambu-gray">{t('smartPlugs.kwhToday')}</p>
  260. </div>
  261. )}
  262. </div>
  263. )}
  264. {/* Toggle Settings Panel */}
  265. <button
  266. onClick={() => setIsExpanded(!isExpanded)}
  267. className="w-full flex items-center justify-between py-2 text-sm text-bambu-gray hover:text-white transition-colors"
  268. >
  269. <span className="flex items-center gap-2">
  270. <Settings2 className="w-4 h-4" />
  271. {plug.plug_type === 'mqtt' ? t('smartPlugs.settings') : t('smartPlugs.automationSettings')}
  272. </span>
  273. <span>{isExpanded ? '-' : '+'}</span>
  274. </button>
  275. {/* Expanded Settings */}
  276. {isExpanded && (
  277. <div className="pt-3 border-t border-bambu-dark-tertiary space-y-4">
  278. {/* Show in Switchbar Toggle */}
  279. <div className="flex items-center justify-between">
  280. <div className="flex items-center gap-2">
  281. <LayoutGrid className="w-4 h-4 text-bambu-green" />
  282. <div>
  283. <p className="text-sm text-white">{t('smartPlugs.showInSwitchbar')}</p>
  284. <p className="text-xs text-bambu-gray">{t('smartPlugs.quickAccessSidebar')}</p>
  285. </div>
  286. </div>
  287. <label className="relative inline-flex items-center cursor-pointer">
  288. <input
  289. type="checkbox"
  290. checked={plug.show_in_switchbar}
  291. onChange={(e) => updateMutation.mutate({ show_in_switchbar: e.target.checked })}
  292. className="sr-only peer"
  293. />
  294. <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>
  295. </label>
  296. </div>
  297. {/* Powers the printer (#2629) - only meaningful with a linked printer.
  298. When off, switching this plug off no longer marks the printer offline. */}
  299. {plug.printer_id != null && (
  300. <div className="flex items-center justify-between">
  301. <div className="flex items-center gap-2">
  302. <Power className="w-4 h-4 text-bambu-green" />
  303. <div>
  304. <p className="text-sm text-white">{t('smartPlugs.controlsPrinterPower')}</p>
  305. <p className="text-xs text-bambu-gray">{t('smartPlugs.controlsPrinterPowerDescription')}</p>
  306. </div>
  307. </div>
  308. <label className="relative inline-flex items-center cursor-pointer">
  309. <input
  310. type="checkbox"
  311. checked={plug.controls_printer_power}
  312. onChange={(e) => updateMutation.mutate({ controls_printer_power: e.target.checked })}
  313. className="sr-only peer"
  314. />
  315. <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>
  316. </label>
  317. </div>
  318. )}
  319. {/* Automation controls - only for controllable plugs (not MQTT) */}
  320. {plug.plug_type !== 'mqtt' && (
  321. <>
  322. {/* Enabled Toggle */}
  323. <div className="flex items-center justify-between">
  324. <div>
  325. <p className="text-sm text-white">{t('smartPlugs.enabled')}</p>
  326. <p className="text-xs text-bambu-gray">{t('smartPlugs.enableAutomation')}</p>
  327. </div>
  328. <label className="relative inline-flex items-center cursor-pointer">
  329. <input
  330. type="checkbox"
  331. checked={plug.enabled}
  332. onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
  333. className="sr-only peer"
  334. />
  335. <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>
  336. </label>
  337. </div>
  338. {/* Auto On */}
  339. <div className="flex items-center justify-between">
  340. <div>
  341. <p className="text-sm text-white">{t('smartPlugs.autoOn')}</p>
  342. <p className="text-xs text-bambu-gray">{t('smartPlugs.autoOnDescription')}</p>
  343. </div>
  344. <label className="relative inline-flex items-center cursor-pointer">
  345. <input
  346. type="checkbox"
  347. checked={plug.auto_on}
  348. onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
  349. className="sr-only peer"
  350. />
  351. <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>
  352. </label>
  353. </div>
  354. {/* Auto Off */}
  355. <div className="flex items-center justify-between">
  356. <div>
  357. <p className="text-sm text-white">{t('smartPlugs.autoOff')}</p>
  358. <p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffDescription')}</p>
  359. </div>
  360. <label className="relative inline-flex items-center cursor-pointer">
  361. <input
  362. type="checkbox"
  363. checked={plug.auto_off}
  364. onChange={(e) => updateMutation.mutate({ auto_off: e.target.checked })}
  365. className="sr-only peer"
  366. />
  367. <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>
  368. </label>
  369. </div>
  370. {/* Auto Off Persistent */}
  371. {plug.auto_off && (
  372. <div className="flex items-center justify-between pl-4 border-l-2 border-bambu-dark-tertiary">
  373. <div>
  374. <p className="text-sm text-white">{t('smartPlugs.autoOffPersistent')}</p>
  375. <p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffPersistentDescription')}</p>
  376. </div>
  377. <label className="relative inline-flex items-center cursor-pointer">
  378. <input
  379. type="checkbox"
  380. checked={plug.auto_off_persistent}
  381. onChange={(e) => updateMutation.mutate({ auto_off_persistent: e.target.checked })}
  382. className="sr-only peer"
  383. />
  384. <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>
  385. </label>
  386. </div>
  387. )}
  388. {/* Delay Mode */}
  389. {plug.auto_off && (
  390. <div className="space-y-3 pl-4 border-l-2 border-bambu-dark-tertiary">
  391. <div>
  392. <p className="text-sm text-white mb-2">{t('smartPlugs.turnOffDelayMode')}</p>
  393. <div className="flex gap-2">
  394. <button
  395. onClick={() => updateMutation.mutate({ off_delay_mode: 'time' })}
  396. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
  397. plug.off_delay_mode === 'time'
  398. ? 'bg-bambu-green text-white'
  399. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  400. }`}
  401. >
  402. <Clock className="w-4 h-4" />
  403. {t('smartPlugs.time')}
  404. </button>
  405. <button
  406. onClick={() => updateMutation.mutate({ off_delay_mode: 'temperature' })}
  407. className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
  408. plug.off_delay_mode === 'temperature'
  409. ? 'bg-bambu-green text-white'
  410. : 'bg-bambu-dark text-bambu-gray hover:text-white'
  411. }`}
  412. >
  413. <Thermometer className="w-4 h-4" />
  414. {t('smartPlugs.temp')}
  415. </button>
  416. </div>
  417. </div>
  418. {plug.off_delay_mode === 'time' ? (
  419. <div>
  420. <label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.delayMinutes')}</label>
  421. <NumberInput
  422. min={1}
  423. max={60}
  424. value={plug.off_delay_minutes}
  425. onChange={(v) => updateMutation.mutate({ off_delay_minutes: v })}
  426. fallback={5}
  427. 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"
  428. />
  429. </div>
  430. ) : (
  431. <div>
  432. <label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.tempThreshold')}</label>
  433. <NumberInput
  434. min={30}
  435. max={100}
  436. value={plug.off_temp_threshold}
  437. onChange={(v) => updateMutation.mutate({ off_temp_threshold: v })}
  438. fallback={70}
  439. 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"
  440. />
  441. <p className="text-xs text-bambu-gray mt-1">{t('smartPlugs.tempThresholdDescription')}</p>
  442. </div>
  443. )}
  444. </div>
  445. )}
  446. {/* Auto Off After Drying (#1349) — independent of the
  447. print-finish auto-off above. Uses its own delay because
  448. the AMS chamber is hot post-cycle and users often want
  449. more cooldown than the print-finish default. Fires when
  450. any AMS attached to the linked printer finishes a dry
  451. cycle. */}
  452. <div className="flex items-center justify-between">
  453. <div>
  454. <p className="text-sm text-white">{t('smartPlugs.autoOffAfterDrying')}</p>
  455. <p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffAfterDryingDescription')}</p>
  456. </div>
  457. <label className="relative inline-flex items-center cursor-pointer">
  458. <input
  459. type="checkbox"
  460. checked={plug.auto_off_after_drying}
  461. onChange={(e) => updateMutation.mutate({ auto_off_after_drying: e.target.checked })}
  462. className="sr-only peer"
  463. />
  464. <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>
  465. </label>
  466. </div>
  467. {plug.auto_off_after_drying && (
  468. <div className="pl-4 border-l-2 border-bambu-dark-tertiary">
  469. <label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.delayAfterDryingMinutes')}</label>
  470. <NumberInput
  471. min={0}
  472. max={120}
  473. value={plug.off_delay_after_drying_minutes}
  474. onChange={(v) => updateMutation.mutate({ off_delay_after_drying_minutes: v })}
  475. fallback={10}
  476. 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"
  477. />
  478. </div>
  479. )}
  480. </>
  481. )}
  482. {/* Action Buttons */}
  483. <div className="flex gap-2 pt-2">
  484. <Button
  485. size="sm"
  486. variant="secondary"
  487. onClick={() => onEdit(plug)}
  488. className="flex-1"
  489. >
  490. <Edit2 className="w-4 h-4" />
  491. {t('smartPlugs.edit')}
  492. </Button>
  493. <Button
  494. size="sm"
  495. variant="secondary"
  496. onClick={() => setShowDeleteConfirm(true)}
  497. className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
  498. >
  499. <Trash2 className="w-4 h-4" />
  500. </Button>
  501. </div>
  502. </div>
  503. )}
  504. </CardContent>
  505. </Card>
  506. {/* Delete Confirmation */}
  507. {showDeleteConfirm && (
  508. <ConfirmModal
  509. title={t('smartPlugs.deleteSmartPlug')}
  510. message={t('smartPlugs.deleteConfirm', { name: plug.name })}
  511. confirmText={t('smartPlugs.delete')}
  512. variant="danger"
  513. onConfirm={() => {
  514. deleteMutation.mutate();
  515. setShowDeleteConfirm(false);
  516. }}
  517. onCancel={() => setShowDeleteConfirm(false)}
  518. />
  519. )}
  520. {/* Power On Confirmation */}
  521. {showPowerOnConfirm && (
  522. <ConfirmModal
  523. title={t('smartPlugs.turnOnSmartPlug')}
  524. message={t('smartPlugs.turnOnConfirm', { name: plug.name })}
  525. confirmText={t('smartPlugs.turnOn')}
  526. variant="default"
  527. onConfirm={() => {
  528. controlMutation.mutate('on');
  529. setShowPowerOnConfirm(false);
  530. }}
  531. onCancel={() => setShowPowerOnConfirm(false)}
  532. />
  533. )}
  534. {/* Power Off Confirmation */}
  535. {showPowerOffConfirm && (
  536. <ConfirmModal
  537. title={t('smartPlugs.turnOffSmartPlug')}
  538. message={t('smartPlugs.turnOffConfirm', { name: plug.name })}
  539. confirmText={t('smartPlugs.turnOff')}
  540. variant="danger"
  541. onConfirm={() => {
  542. controlMutation.mutate('off');
  543. setShowPowerOffConfirm(false);
  544. }}
  545. onCancel={() => setShowPowerOffConfirm(false)}
  546. />
  547. )}
  548. </>
  549. );
  550. }