LocationSensorOptionsModal.tsx 19 KB

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