LocationSensorOptionsModal.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. import { useEffect, useRef, useState } from 'react';
  2. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { Battery, Droplets, RotateCcw, Save, Settings2, Thermometer, X } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import type { AppSettingsUpdate } from '../api/client';
  7. import { Button } from './Button';
  8. import { ConfirmModal } from './ConfirmModal';
  9. import { useToast } from '../contexts/ToastContext';
  10. import {
  11. LOCATION_SENSOR_ALERT_COLORS,
  12. loadLocationSensorAlertAboveColor,
  13. loadLocationSensorAlertBelowColor,
  14. loadLocationSensorAlertOptimalColor,
  15. loadLocationSensorColorizeValues,
  16. loadLocationSensorDefaults,
  17. saveLocationSensorAlertAboveColor,
  18. saveLocationSensorAlertBelowColor,
  19. saveLocationSensorAlertOptimalColor,
  20. saveLocationSensorColorizeValues,
  21. saveLocationSensorShowOnCardDefaults,
  22. serializeLocationSensorAlertDefaults,
  23. type LocationSensorAlertColor,
  24. type LocationSensorCategory,
  25. type LocationSensorCategoryDefaults,
  26. type LocationSensorDefaults,
  27. } from '../utils/locationSensorDefaults';
  28. interface Props {
  29. onClose: () => void;
  30. }
  31. const MIN_POLL_INTERVAL = 60;
  32. const DEFAULT_POLL_INTERVAL = 120;
  33. const CATEGORY_ICONS: Record<LocationSensorCategory, typeof Thermometer> = {
  34. temperature: Thermometer,
  35. humidity: Droplets,
  36. battery: Battery,
  37. };
  38. const CATEGORY_UNITS: Record<LocationSensorCategory, string> = {
  39. temperature: '°C',
  40. humidity: '%',
  41. battery: '%',
  42. };
  43. // Keep in step with categoryFor in LocationHASensorModal — "moisture" is
  44. // binary wet/dry, not a humidity percentage, and is not a location category.
  45. function categoryFor(deviceClass: string | null): LocationSensorCategory | null {
  46. if (deviceClass === 'temperature') return 'temperature';
  47. if (deviceClass === 'humidity') return 'humidity';
  48. if (deviceClass === 'battery') return 'battery';
  49. return null;
  50. }
  51. function CategorySection({
  52. category,
  53. state,
  54. onChange,
  55. }: {
  56. category: LocationSensorCategory;
  57. state: LocationSensorCategoryDefaults;
  58. onChange: (patch: Partial<LocationSensorCategoryDefaults>) => void;
  59. }) {
  60. const { t } = useTranslation();
  61. const Icon = CATEGORY_ICONS[category];
  62. const unit = CATEGORY_UNITS[category];
  63. const hasAlertCondition = state.alertAbove !== '' || state.alertBelow !== '';
  64. return (
  65. <div className="p-3 border border-bambu-dark-tertiary rounded-lg space-y-3">
  66. <div className="flex items-center gap-1.5 text-sm text-white font-medium">
  67. <Icon className="w-4 h-4" />
  68. {t(`inventory.${category}`)}
  69. </div>
  70. {category === 'battery' ? (
  71. <div>
  72. <span className="block text-xs text-bambu-gray mb-1">
  73. {t('haSensors.alertBelow')} {unit}
  74. </span>
  75. <input
  76. type="number"
  77. step="any"
  78. value={state.alertBelow}
  79. onChange={(e) => onChange({ alertBelow: e.target.value })}
  80. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  81. />
  82. </div>
  83. ) : (
  84. <div className="grid grid-cols-2 gap-3">
  85. <div>
  86. <span className="block text-xs text-bambu-gray mb-1">
  87. {t('haSensors.alertAbove')} {unit}
  88. </span>
  89. <input
  90. type="number"
  91. step="any"
  92. value={state.alertAbove}
  93. onChange={(e) => onChange({ alertAbove: e.target.value })}
  94. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  95. />
  96. </div>
  97. <div>
  98. <span className="block text-xs text-bambu-gray mb-1">
  99. {t('haSensors.alertBelow')} {unit}
  100. </span>
  101. <input
  102. type="number"
  103. step="any"
  104. value={state.alertBelow}
  105. onChange={(e) => onChange({ alertBelow: e.target.value })}
  106. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  107. />
  108. </div>
  109. </div>
  110. )}
  111. <label className="flex items-center gap-3 cursor-pointer">
  112. <input
  113. type="checkbox"
  114. checked={state.showOnCard}
  115. onChange={(e) => onChange({ showOnCard: e.target.checked })}
  116. className="w-4 h-4"
  117. />
  118. <span className="text-sm text-white">{t('locationHaSensors.showOnCard')}</span>
  119. </label>
  120. <label className="flex items-center gap-3 cursor-pointer">
  121. <input
  122. type="checkbox"
  123. checked={state.notifyOnAlert}
  124. onChange={(e) => onChange({ notifyOnAlert: e.target.checked })}
  125. disabled={!hasAlertCondition}
  126. className="w-4 h-4"
  127. />
  128. <span className={`text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
  129. {t('haSensors.notifyOnAlert')}
  130. </span>
  131. </label>
  132. </div>
  133. );
  134. }
  135. export function LocationSensorOptionsModal({ onClose }: Props) {
  136. const { t } = useTranslation();
  137. const { showToast } = useToast();
  138. const queryClient = useQueryClient();
  139. // Built-ins first, then seeded from the server once the settings query
  140. // lands. The alert fields come from `location_sensor_alert_defaults`;
  141. // show-on-card is still local.
  142. const [defaults, setDefaults] = useState<LocationSensorDefaults>(() => loadLocationSensorDefaults());
  143. const [colorizeValues, setColorizeValues] = useState(() => loadLocationSensorColorizeValues());
  144. const [aboveColor, setAboveColor] = useState<LocationSensorAlertColor>(() => loadLocationSensorAlertAboveColor());
  145. const [belowColor, setBelowColor] = useState<LocationSensorAlertColor>(() => loadLocationSensorAlertBelowColor());
  146. const [optimalColor, setOptimalColor] = useState<LocationSensorAlertColor>(() => loadLocationSensorAlertOptimalColor());
  147. const [showResetConfirm, setShowResetConfirm] = useState(false);
  148. const { data: appSettings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
  149. const [pollInterval, setPollInterval] = useState(DEFAULT_POLL_INTERVAL);
  150. // Seed the server-backed fields once, and never over an edit in progress.
  151. // The dialog renders immediately with placeholder values, so a settings
  152. // response landing mid-keystroke would otherwise put the old value back in
  153. // front of what was just typed (a field cleared and retyped came out as
  154. // "3035"). In practice ['settings'] is warm — SettingsPage, which opens this
  155. // dialog, already holds it — so this only covers the cold path and a
  156. // background refetch.
  157. //
  158. // "Seeded" and "touched" are tracked apart on purpose. One flag for both
  159. // means a keystroke that beats the response cancels the seed outright, and
  160. // Save then writes built-in defaults over the server values for every field
  161. // the user never saw. Seeding therefore always happens; it just skips the
  162. // fields already edited, which are named here rather than counted.
  163. const seeded = useRef(false);
  164. const touched = useRef(new Set<LocationSensorCategory | 'pollInterval'>());
  165. useEffect(() => {
  166. if (!appSettings || seeded.current) return;
  167. seeded.current = true;
  168. if (!touched.current.has('pollInterval')) setPollInterval(appSettings.location_sensor_poll_interval);
  169. const fromServer = loadLocationSensorDefaults(appSettings.location_sensor_alert_defaults);
  170. setDefaults((prev) => {
  171. const next = { ...fromServer };
  172. (Object.keys(next) as LocationSensorCategory[]).forEach((category) => {
  173. if (touched.current.has(category)) next[category] = prev[category];
  174. });
  175. return next;
  176. });
  177. }, [appSettings]);
  178. const updateCategory = (category: LocationSensorCategory, patch: Partial<LocationSensorCategoryDefaults>) => {
  179. touched.current.add(category);
  180. setDefaults((prev) => ({ ...prev, [category]: { ...prev[category], ...patch } }));
  181. };
  182. const updatePollInterval = (value: number) => {
  183. touched.current.add('pollInterval');
  184. setPollInterval(value);
  185. };
  186. const persistDefaults = async () => {
  187. // Server call first, and only for fields that actually changed (these
  188. // need SETTINGS_UPDATE/admin), before writing anything to localStorage.
  189. // A failed PATCH must leave the local preferences below untouched, so the
  190. // error toast that follows is true — nothing was saved, not "half of it
  191. // was".
  192. const clampedInterval = Math.max(MIN_POLL_INTERVAL, pollInterval);
  193. const alertDefaults = serializeLocationSensorAlertDefaults({
  194. ...defaults,
  195. battery: { ...defaults.battery, alertAbove: '' },
  196. });
  197. const patch: AppSettingsUpdate = {};
  198. if (appSettings && clampedInterval !== appSettings.location_sensor_poll_interval) {
  199. patch.location_sensor_poll_interval = clampedInterval;
  200. }
  201. if (appSettings && alertDefaults !== appSettings.location_sensor_alert_defaults) {
  202. patch.location_sensor_alert_defaults = alertDefaults;
  203. }
  204. if (Object.keys(patch).length > 0) {
  205. await api.updateSettings(patch);
  206. queryClient.invalidateQueries({ queryKey: ['settings'] });
  207. }
  208. saveLocationSensorShowOnCardDefaults(defaults);
  209. saveLocationSensorColorizeValues(colorizeValues);
  210. saveLocationSensorAlertAboveColor(aboveColor);
  211. saveLocationSensorAlertBelowColor(belowColor);
  212. saveLocationSensorAlertOptimalColor(optimalColor);
  213. };
  214. const saveMutation = useMutation({
  215. mutationFn: persistDefaults,
  216. onSuccess: () => {
  217. showToast(t('locationHaSensors.options.saved'), 'success');
  218. onClose();
  219. },
  220. onError: (err: Error) => {
  221. showToast(err.message || t('locationHaSensors.options.saveFailed'), 'error');
  222. },
  223. });
  224. const handleSave = (e: React.FormEvent) => {
  225. e.preventDefault();
  226. saveMutation.mutate();
  227. };
  228. const resetMutation = useMutation({
  229. mutationFn: async () => {
  230. // Sensors first, options after — the same write-order rule Save follows,
  231. // one level up. Reset is the risky half: it rewrites every bound sensor,
  232. // and if that fails the error toast has to mean "nothing was saved". The
  233. // per-sensor PATCHes below stay individually non-atomic (there is no bulk
  234. // route), so a failure part-way still leaves some rows reset — but it no
  235. // longer also leaves the options saved against a reset that half ran.
  236. const [sensors, entities] = await Promise.all([api.getLocationHASensors(), api.getBindableLocationHAEntities()]);
  237. const friendlyNameByEntityId = new Map(entities.map((entity) => [entity.entity_id, entity.friendly_name]));
  238. const targets = sensors.filter((sensor) => categoryFor(sensor.device_class) !== null);
  239. await Promise.all(
  240. targets.map((sensor) => {
  241. const category = categoryFor(sensor.device_class)!;
  242. const categoryDefaults = defaults[category];
  243. const friendlyName = friendlyNameByEntityId.get(sensor.entity_id);
  244. return api.updateLocationHASensor(sensor.id, {
  245. ...(friendlyName ? { name: friendlyName.slice(0, 100) } : {}),
  246. alert_above:
  247. category !== 'battery' && categoryDefaults.alertAbove !== '' ? Number(categoryDefaults.alertAbove) : null,
  248. alert_below: categoryDefaults.alertBelow !== '' ? Number(categoryDefaults.alertBelow) : null,
  249. notify_on_alert: categoryDefaults.notifyOnAlert,
  250. show_on_card: categoryDefaults.showOnCard,
  251. });
  252. })
  253. );
  254. await persistDefaults();
  255. return targets.length;
  256. },
  257. onSuccess: (count) => {
  258. queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] });
  259. queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] });
  260. showToast(t('locationHaSensors.options.resetDone', { count }), 'success');
  261. setShowResetConfirm(false);
  262. onClose();
  263. },
  264. onError: (err: Error) => {
  265. showToast(err.message || t('locationHaSensors.options.resetFailed'), 'error');
  266. setShowResetConfirm(false);
  267. },
  268. });
  269. return (
  270. <>
  271. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={onClose}>
  272. <div
  273. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto"
  274. onClick={(e) => e.stopPropagation()}
  275. >
  276. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  277. <div className="flex items-center gap-3">
  278. <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
  279. <Settings2 className="w-5 h-5" />
  280. </div>
  281. <h2 className="text-lg font-semibold text-white">{t('locationHaSensors.options.title')}</h2>
  282. </div>
  283. <button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors">
  284. <X className="w-5 h-5" />
  285. </button>
  286. </div>
  287. <form onSubmit={handleSave} className="px-6 pb-6 pt-3 space-y-4">
  288. <p className="text-xs text-bambu-gray">{t('locationHaSensors.options.description')}</p>
  289. <div className="space-y-3">
  290. <CategorySection
  291. category="temperature"
  292. state={defaults.temperature}
  293. onChange={(patch) => updateCategory('temperature', patch)}
  294. />
  295. <CategorySection
  296. category="humidity"
  297. state={defaults.humidity}
  298. onChange={(patch) => updateCategory('humidity', patch)}
  299. />
  300. <CategorySection
  301. category="battery"
  302. state={defaults.battery}
  303. onChange={(patch) => updateCategory('battery', patch)}
  304. />
  305. </div>
  306. <div className="p-3 border border-bambu-dark-tertiary rounded-lg space-y-3">
  307. <label className="flex items-center gap-3 cursor-pointer">
  308. <input
  309. type="checkbox"
  310. checked={colorizeValues}
  311. onChange={(e) => setColorizeValues(e.target.checked)}
  312. className="w-4 h-4"
  313. />
  314. <span className="text-sm text-white">{t('locationHaSensors.options.colorizeValues')}</span>
  315. </label>
  316. <div className={`grid grid-cols-3 gap-x-3 gap-y-1 items-end ${colorizeValues ? '' : 'opacity-50'}`}>
  317. <label className="block text-xs text-bambu-gray" htmlFor="location-sensor-below-color">
  318. {t('locationHaSensors.options.belowColor')}
  319. </label>
  320. <label className="block text-xs text-bambu-gray" htmlFor="location-sensor-optimal-color">
  321. {t('locationHaSensors.options.optimalColor')}
  322. </label>
  323. <label className="block text-xs text-bambu-gray" htmlFor="location-sensor-above-color">
  324. {t('locationHaSensors.options.aboveColor')}
  325. </label>
  326. <select
  327. id="location-sensor-below-color"
  328. value={belowColor}
  329. onChange={(e) => setBelowColor(e.target.value as LocationSensorAlertColor)}
  330. disabled={!colorizeValues}
  331. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:cursor-not-allowed"
  332. >
  333. {LOCATION_SENSOR_ALERT_COLORS.map((color) => (
  334. <option key={color} value={color}>
  335. {t(`locationHaSensors.options.colors.${color}`)}
  336. </option>
  337. ))}
  338. </select>
  339. <select
  340. id="location-sensor-optimal-color"
  341. value={optimalColor}
  342. onChange={(e) => setOptimalColor(e.target.value as LocationSensorAlertColor)}
  343. disabled={!colorizeValues}
  344. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:cursor-not-allowed"
  345. >
  346. {LOCATION_SENSOR_ALERT_COLORS.map((color) => (
  347. <option key={color} value={color}>
  348. {t(`locationHaSensors.options.colors.${color}`)}
  349. </option>
  350. ))}
  351. </select>
  352. <select
  353. id="location-sensor-above-color"
  354. value={aboveColor}
  355. onChange={(e) => setAboveColor(e.target.value as LocationSensorAlertColor)}
  356. disabled={!colorizeValues}
  357. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:cursor-not-allowed"
  358. >
  359. {LOCATION_SENSOR_ALERT_COLORS.map((color) => (
  360. <option key={color} value={color}>
  361. {t(`locationHaSensors.options.colors.${color}`)}
  362. </option>
  363. ))}
  364. </select>
  365. </div>
  366. </div>
  367. {/* Everything above is local display preference (localStorage); the
  368. poll interval below is the one field here that actually lives on
  369. the server, hence its own heading. */}
  370. <div className="pt-4 mt-4 border-t border-bambu-dark-tertiary">
  371. <p className="text-xs font-medium text-bambu-gray uppercase tracking-wider mb-3">
  372. {t('locationHaSensors.options.generalSettings')}
  373. </p>
  374. </div>
  375. <div className="p-3 border border-bambu-dark-tertiary rounded-lg space-y-2">
  376. <label className="block text-sm text-white" htmlFor="location-sensor-poll-interval">
  377. {t('locationHaSensors.options.pollInterval')}
  378. </label>
  379. <input
  380. id="location-sensor-poll-interval"
  381. type="number"
  382. min={MIN_POLL_INTERVAL}
  383. step="1"
  384. value={pollInterval}
  385. onChange={(e) => updatePollInterval(Number(e.target.value))}
  386. onBlur={() => updatePollInterval(Math.max(MIN_POLL_INTERVAL, pollInterval || DEFAULT_POLL_INTERVAL))}
  387. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  388. />
  389. <p className="text-xs text-bambu-gray">{t('locationHaSensors.options.pollIntervalHint')}</p>
  390. </div>
  391. <div className="flex items-center justify-between gap-2 pt-2">
  392. <Button type="button" variant="secondary" onClick={() => setShowResetConfirm(true)}>
  393. <RotateCcw className="w-4 h-4" />
  394. {t('locationHaSensors.options.reset')}
  395. </Button>
  396. <div className="flex items-center gap-2">
  397. <Button type="button" variant="secondary" onClick={onClose}>
  398. {t('common.cancel')}
  399. </Button>
  400. <Button type="submit" disabled={saveMutation.isPending}>
  401. <Save className="w-4 h-4" />
  402. {t('common.save')}
  403. </Button>
  404. </div>
  405. </div>
  406. </form>
  407. </div>
  408. </div>
  409. {showResetConfirm && (
  410. <ConfirmModal
  411. title={t('locationHaSensors.options.resetConfirm.title')}
  412. message={t('locationHaSensors.options.resetConfirm.message')}
  413. confirmText={t('locationHaSensors.options.reset')}
  414. variant="danger"
  415. overlayZIndex="z-[60]"
  416. isLoading={resetMutation.isPending}
  417. onConfirm={() => resetMutation.mutate()}
  418. onCancel={() => setShowResetConfirm(false)}
  419. />
  420. )}
  421. </>
  422. );
  423. }