LocationHASensorModal.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. import { useEffect, useMemo, useRef, useState } from 'react';
  2. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  3. import { Gauge, Loader2, Plus, Save, Search, X } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../api/client';
  6. import type { HADisplayEntity, LocationHASensor, StorageLocation } from '../api/client';
  7. import { Button } from './Button';
  8. import { ConfirmModal } from './ConfirmModal';
  9. import { LocationsModal } from './LocationsModal';
  10. import { useToast } from '../contexts/ToastContext';
  11. import { loadLocationSensorDefaults } from '../utils/locationSensorDefaults';
  12. import { HA_SENSOR_BINARY_LABELS } from '../utils/haSensorDisplay';
  13. interface Props {
  14. sensor?: LocationHASensor | null;
  15. locations: StorageLocation[];
  16. onClose: () => void;
  17. }
  18. type SensorCategory = 'temperature' | 'humidity' | 'battery';
  19. // Doubles as the picker's filter (see `entities` below): an entity with no
  20. // category cannot be bound to a location at all.
  21. //
  22. // "moisture" is deliberately absent. It is Home Assistant's binary wet/dry
  23. // class, not a humidity percentage, and mapping it here made it a humidity
  24. // sensor everywhere downstream — it landed in the percent-formatted humidity
  25. // column rendering "wet", blocked a real humidity sensor on the same location
  26. // via the one-per-category rule, and could never take the seeded thresholds
  27. // because the schema rejects alert_above/alert_below for kind="binary".
  28. // A storage location wants a hygrometer, not a leak detector.
  29. function categoryFor(deviceClass: string | null): SensorCategory | null {
  30. if (deviceClass === 'temperature') return 'temperature';
  31. if (deviceClass === 'humidity') return 'humidity';
  32. if (deviceClass === 'battery') return 'battery';
  33. return null;
  34. }
  35. const CATEGORY_SUFFIXES: Record<SensorCategory, string> = {
  36. temperature: 'temperature',
  37. humidity: 'humidity',
  38. battery: 'battery',
  39. };
  40. function findSiblingEntities(
  41. entityId: string,
  42. category: SensorCategory,
  43. candidates: HADisplayEntity[]
  44. ): HADisplayEntity[] {
  45. const suffix = CATEGORY_SUFFIXES[category];
  46. const lower = entityId.toLowerCase();
  47. if (!lower.endsWith(suffix)) return [];
  48. const prefix = entityId.slice(0, entityId.length - suffix.length);
  49. const siblings: HADisplayEntity[] = [];
  50. (Object.keys(CATEGORY_SUFFIXES) as SensorCategory[]).forEach((otherCategory) => {
  51. if (otherCategory === category) return;
  52. const candidateId = `${prefix}${CATEGORY_SUFFIXES[otherCategory]}`.toLowerCase();
  53. const match = candidates.find((c) => c.entity_id.toLowerCase() === candidateId);
  54. if (match) siblings.push(match);
  55. });
  56. return siblings;
  57. }
  58. export function LocationHASensorModal({ sensor, locations, onClose }: Props) {
  59. const { t } = useTranslation();
  60. const queryClient = useQueryClient();
  61. const { showToast } = useToast();
  62. const isEditing = !!sensor;
  63. const [locationId, setLocationId] = useState<number | ''>(sensor?.location_id ?? locations[0]?.id ?? '');
  64. const [entityId, setEntityId] = useState(sensor?.entity_id ?? '');
  65. const [kind, setKind] = useState<'binary' | 'numeric'>(sensor?.kind ?? 'numeric');
  66. const [deviceClass, setDeviceClass] = useState<string | null>(sensor?.device_class ?? null);
  67. const [unit, setUnit] = useState<string | null>(sensor?.unit ?? null);
  68. const [name, setName] = useState(sensor?.name ?? '');
  69. // Tracks the last name we auto-filled (or the initial saved name), so
  70. // switching to a different entity can follow along with the new friendly
  71. // name — but only while the field still holds what we put there. A name
  72. // the user typed themselves is never overwritten by an entity change.
  73. const autoFilledNameRef = useRef(sensor?.name ?? '');
  74. const [alertState, setAlertState] = useState<'on' | 'off' | ''>(sensor?.alert_state ?? '');
  75. const [alertAbove, setAlertAbove] = useState(sensor?.alert_above?.toString() ?? '');
  76. const [alertBelow, setAlertBelow] = useState(sensor?.alert_below?.toString() ?? '');
  77. const [notifyOnAlert, setNotifyOnAlert] = useState(sensor?.notify_on_alert ?? false);
  78. const [showOnCard, setShowOnCard] = useState(sensor?.show_on_card ?? true);
  79. const [search, setSearch] = useState('');
  80. const [error, setError] = useState<string | null>(null);
  81. const [showAddLocationModal, setShowAddLocationModal] = useState(false);
  82. const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
  83. const [overwriteTarget, setOverwriteTarget] = useState<LocationHASensor | null>(null);
  84. const [showAutoAddConfirm, setShowAutoAddConfirm] = useState(false);
  85. const [autoAddCandidates, setAutoAddCandidates] = useState<HADisplayEntity[]>([]);
  86. const [autoAddSelected, setAutoAddSelected] = useState<Record<string, boolean>>({});
  87. const { data: settings } = useQuery({
  88. queryKey: ['settings'],
  89. queryFn: api.getSettings,
  90. });
  91. const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
  92. const { data: rawEntities, isLoading: entitiesLoading, error: entitiesError } = useQuery({
  93. queryKey: ['bindableLocationHAEntities'],
  94. queryFn: () => api.getBindableLocationHAEntities(),
  95. enabled: haConfigured,
  96. });
  97. const entities = useMemo(
  98. () => (rawEntities ?? []).filter((e) => categoryFor(e.device_class) !== null),
  99. [rawEntities]
  100. );
  101. const { data: allLocationSensors } = useQuery({
  102. queryKey: ['locationHaSensors'],
  103. queryFn: () => api.getLocationHASensors(),
  104. });
  105. const selected = entities.find((e) => e.entity_id === entityId);
  106. const filtered = useMemo(() => {
  107. const needle = search.trim().toLowerCase();
  108. const all = entities ?? [];
  109. const matches = needle
  110. ? all.filter((e) => e.entity_id.toLowerCase().includes(needle) || e.friendly_name.toLowerCase().includes(needle))
  111. : all;
  112. if (!selected || matches[0]?.entity_id === selected.entity_id) return matches;
  113. return [selected, ...matches.filter((e) => e.entity_id !== selected.entity_id)];
  114. }, [entities, search, selected]);
  115. const selectEntity = (entity: HADisplayEntity) => {
  116. setEntityId(entity.entity_id);
  117. setDeviceClass(entity.device_class);
  118. // Sliced like the name below: the unit is snapshotted from Home Assistant,
  119. // not typed by the user, so an oversized one must not come back as a 422
  120. // on a field the form never showed them. The column is String(16).
  121. setUnit(entity.unit_of_measurement?.slice(0, 16) ?? null);
  122. const nextKind = entity.domain === 'binary_sensor' ? 'binary' : 'numeric';
  123. setKind(nextKind);
  124. if (nextKind === 'numeric') setAlertState('');
  125. else {
  126. setAlertAbove('');
  127. setAlertBelow('');
  128. }
  129. // Sliced to the column width: Home Assistant friendly names have no length
  130. // limit, and a long one would come back as a Pydantic error on a field the
  131. // user did not type into. Follows the entity picker as long as the name
  132. // still matches what we last auto-filled — a name the user typed
  133. // themselves is left alone even when they pick a different entity.
  134. if (name === autoFilledNameRef.current) {
  135. const nextName = entity.friendly_name.slice(0, 100);
  136. setName(nextName);
  137. autoFilledNameRef.current = nextName;
  138. }
  139. if (!isEditing) {
  140. const category = categoryFor(entity.device_class);
  141. if (category) {
  142. const defaults = loadLocationSensorDefaults(settings?.location_sensor_alert_defaults)[category];
  143. if (nextKind === 'numeric') {
  144. setAlertAbove(category === 'battery' ? '' : defaults.alertAbove);
  145. setAlertBelow(defaults.alertBelow);
  146. }
  147. setNotifyOnAlert(defaults.notifyOnAlert);
  148. setShowOnCard(defaults.showOnCard);
  149. }
  150. }
  151. };
  152. const invalidate = () => {
  153. queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] });
  154. queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] });
  155. };
  156. const buildPrimaryPayload = () => ({
  157. name: name.trim(),
  158. entity_id: entityId,
  159. kind,
  160. device_class: deviceClass,
  161. unit,
  162. alert_state: kind === 'binary' && alertState ? alertState : null,
  163. alert_above:
  164. kind === 'numeric' && categoryFor(deviceClass) !== 'battery' && alertAbove !== '' ? Number(alertAbove) : null,
  165. alert_below: kind === 'numeric' && alertBelow !== '' ? Number(alertBelow) : null,
  166. notify_on_alert: notifyOnAlert,
  167. show_on_card: showOnCard,
  168. });
  169. const saveMutation = useMutation({
  170. mutationFn: () => {
  171. const payload = buildPrimaryPayload();
  172. return isEditing
  173. ? api.updateLocationHASensor(sensor.id, payload)
  174. : api.createLocationHASensor({ ...payload, location_id: Number(locationId) });
  175. },
  176. onSuccess: () => {
  177. invalidate();
  178. showToast(isEditing ? t('locationHaSensors.toast.updated') : t('locationHaSensors.toast.created'), 'success');
  179. onClose();
  180. },
  181. onError: (err: Error) => setError(err.message),
  182. });
  183. const autoAddMutation = useMutation({
  184. mutationFn: async () => {
  185. const targetLocationId = Number(locationId);
  186. await api.createLocationHASensor({ ...buildPrimaryPayload(), location_id: targetLocationId });
  187. const defaults = loadLocationSensorDefaults(settings?.location_sensor_alert_defaults);
  188. const chosen = autoAddCandidates.filter((entity) => autoAddSelected[entity.entity_id]);
  189. for (const entity of chosen) {
  190. const categoryDefaults = categoryFor(entity.device_class);
  191. const d = categoryDefaults ? defaults[categoryDefaults] : null;
  192. await api.createLocationHASensor({
  193. name: entity.friendly_name.slice(0, 100),
  194. entity_id: entity.entity_id,
  195. kind: entity.domain === 'binary_sensor' ? 'binary' : 'numeric',
  196. device_class: entity.device_class,
  197. unit: entity.unit_of_measurement?.slice(0, 16) ?? null,
  198. alert_state: null,
  199. alert_above: categoryDefaults !== 'battery' && d && d.alertAbove !== '' ? Number(d.alertAbove) : null,
  200. alert_below: d && d.alertBelow !== '' ? Number(d.alertBelow) : null,
  201. notify_on_alert: d?.notifyOnAlert ?? false,
  202. show_on_card: d?.showOnCard ?? showOnCard,
  203. location_id: targetLocationId,
  204. });
  205. }
  206. return chosen;
  207. },
  208. onSuccess: (chosen) => {
  209. invalidate();
  210. setShowAutoAddConfirm(false);
  211. if (chosen.length > 0) {
  212. showToast(t('locationHaSensors.autoAdd.added', { names: chosen.map((e) => e.entity_id).join(', ') }), 'success');
  213. } else {
  214. showToast(t('locationHaSensors.toast.created'), 'success');
  215. }
  216. onClose();
  217. },
  218. onError: (err: Error) => {
  219. invalidate();
  220. setShowAutoAddConfirm(false);
  221. setError(err.message);
  222. },
  223. });
  224. const deleteMutation = useMutation({
  225. mutationFn: () => api.deleteLocationHASensor(sensor!.id),
  226. onSuccess: () => {
  227. invalidate();
  228. showToast(t('locationHaSensors.toast.deleted'), 'success');
  229. onClose();
  230. },
  231. onError: (err: Error) => setError(err.message),
  232. });
  233. const overwriteMutation = useMutation({
  234. // PATCH the existing row onto the new entity instead of deleting it and
  235. // creating a replacement: a delete-then-create left a window where, if
  236. // the create failed, the old binding was already gone and nothing had
  237. // taken its place. A single PATCH either lands or leaves the original
  238. // binding untouched.
  239. mutationFn: () => api.updateLocationHASensor(overwriteTarget!.id, buildPrimaryPayload()),
  240. onSuccess: () => {
  241. invalidate();
  242. setShowOverwriteConfirm(false);
  243. setOverwriteTarget(null);
  244. showToast(t('locationHaSensors.toast.updated'), 'success');
  245. onClose();
  246. },
  247. onError: (err: Error) => {
  248. setShowOverwriteConfirm(false);
  249. setOverwriteTarget(null);
  250. setError(err.message);
  251. },
  252. });
  253. const hasAlertCondition = kind === 'binary' ? alertState !== '' : alertAbove !== '' || alertBelow !== '';
  254. const handleSubmit = (e: React.FormEvent) => {
  255. e.preventDefault();
  256. setError(null);
  257. if (!entityId) return setError(t('haSensors.error.pickEntity'));
  258. if (!name.trim()) return setError(t('haSensors.error.nameRequired'));
  259. if (locationId === '') return setError(t('locationHaSensors.error.locationRequired'));
  260. if (notifyOnAlert && !hasAlertCondition) {
  261. return setError(t('haSensors.error.alertRequired'));
  262. }
  263. if (!isEditing) {
  264. const locationSensors = (allLocationSensors ?? []).filter((s) => s.location_id === Number(locationId));
  265. const category = categoryFor(deviceClass);
  266. if (category) {
  267. const conflicting = locationSensors.find((s) => categoryFor(s.device_class) === category);
  268. if (conflicting) {
  269. setOverwriteTarget(conflicting);
  270. setShowOverwriteConfirm(true);
  271. return;
  272. }
  273. }
  274. if (locationSensors.length === 0 && category) {
  275. const siblings = findSiblingEntities(entityId, category, entities);
  276. if (siblings.length > 0) {
  277. setAutoAddCandidates(siblings);
  278. setAutoAddSelected(Object.fromEntries(siblings.map((s) => [s.entity_id, true])));
  279. setShowAutoAddConfirm(true);
  280. return;
  281. }
  282. showToast(t('locationHaSensors.autoAdd.noneFound'), 'info');
  283. }
  284. }
  285. saveMutation.mutate();
  286. };
  287. const alertLabels = HA_SENSOR_BINARY_LABELS[deviceClass ?? ''];
  288. const stateLabel = (which: 'on' | 'off') => {
  289. const key = alertLabels?.[which] ?? which;
  290. return t(`haSensors.states.${key}`, { defaultValue: key });
  291. };
  292. const isPending =
  293. saveMutation.isPending || deleteMutation.isPending || overwriteMutation.isPending || autoAddMutation.isPending;
  294. const currentLocation = locations.find((l) => l.id === Number(locationId));
  295. // Escape closes the modal, but not while a mutation is mid-flight — a
  296. // stray keypress landing between the overwrite PATCH's dispatch and its
  297. // response must not drop the user out with an orphaned request.
  298. useEffect(() => {
  299. const onKey = (e: KeyboardEvent) => {
  300. if (e.key === 'Escape' && !isPending) onClose();
  301. };
  302. window.addEventListener('keydown', onKey);
  303. return () => window.removeEventListener('keydown', onKey);
  304. }, [onClose, isPending]);
  305. return (
  306. <>
  307. <div
  308. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  309. onClick={() => {
  310. if (!isPending) onClose();
  311. }}
  312. >
  313. <div
  314. className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto"
  315. onClick={(e) => e.stopPropagation()}
  316. >
  317. <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
  318. <div className="flex items-center gap-3">
  319. <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
  320. <Gauge className="w-5 h-5" />
  321. </div>
  322. <h2 className="text-lg font-semibold text-white">
  323. {isEditing ? t('locationHaSensors.editTitle') : t('locationHaSensors.addTitle')}
  324. </h2>
  325. </div>
  326. <button
  327. onClick={onClose}
  328. disabled={isPending}
  329. className="text-bambu-gray hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
  330. >
  331. <X className="w-5 h-5" />
  332. </button>
  333. </div>
  334. <form onSubmit={handleSubmit} className="p-6 space-y-4">
  335. {error && (
  336. <div className="p-3 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
  337. {error}
  338. </div>
  339. )}
  340. <div>
  341. <label className="block text-sm text-bambu-gray mb-1">{t('locationHaSensors.location')}</label>
  342. {isEditing ? (
  343. <div className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white">
  344. {locations.find((l) => l.id === sensor.location_id)?.name ?? t('locationHaSensors.unknownLocation')}
  345. </div>
  346. ) : (
  347. <div className="flex items-center gap-2">
  348. <select
  349. value={locationId}
  350. onChange={(e) => setLocationId(e.target.value === '' ? '' : Number(e.target.value))}
  351. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  352. >
  353. {locations.map((l) => (
  354. <option key={l.id} value={l.id}>
  355. {l.name}
  356. </option>
  357. ))}
  358. </select>
  359. <button
  360. type="button"
  361. onClick={() => setShowAddLocationModal(true)}
  362. className="p-2 rounded-lg bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white transition-colors shrink-0"
  363. title={t('locations.add')}
  364. aria-label={t('locations.add')}
  365. >
  366. <Plus className="w-4 h-4" />
  367. </button>
  368. </div>
  369. )}
  370. </div>
  371. {!haConfigured && (
  372. <div className="p-3 bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-400 dark:border-yellow-500/50 rounded-lg text-sm text-yellow-700 dark:text-yellow-400">
  373. {t('smartPlugs.haNotConfigured')}{' '}
  374. <span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
  375. </div>
  376. )}
  377. <div>
  378. <label className={`block text-sm text-bambu-gray mb-1 ${haConfigured ? '' : 'opacity-50'}`}>
  379. {t('haSensors.entity')}
  380. </label>
  381. {entitiesError && (
  382. <div className="p-3 mb-2 bg-red-100 dark:bg-red-500/20 rounded-lg text-sm text-red-700 dark:text-red-400">
  383. {(entitiesError as Error).message}
  384. </div>
  385. )}
  386. <div className="relative mb-2">
  387. <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
  388. <input
  389. type="text"
  390. value={search}
  391. onChange={(e) => setSearch(e.target.value)}
  392. placeholder={t('haSensors.searchPlaceholder')}
  393. disabled={!haConfigured}
  394. className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed"
  395. />
  396. </div>
  397. <div
  398. className={`max-h-44 overflow-y-auto rounded-lg border border-bambu-dark-tertiary ${
  399. haConfigured ? '' : 'opacity-50'
  400. }`}
  401. >
  402. {entityId && !selected && (
  403. <div className="px-3 py-2 text-sm bg-bambu-green/10 text-bambu-green border-b border-bambu-dark-tertiary">
  404. {t('locationHaSensors.currentlyBound', { entity: entityId })}
  405. </div>
  406. )}
  407. {!haConfigured && <div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>}
  408. {haConfigured && entitiesLoading && (
  409. <div className="flex items-center gap-2 p-3 text-sm text-bambu-gray">
  410. <Loader2 className="w-4 h-4 animate-spin" />
  411. {t('common.loading')}
  412. </div>
  413. )}
  414. {haConfigured && !entitiesLoading && filtered.length === 0 && (
  415. <div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
  416. )}
  417. {haConfigured &&
  418. !entitiesLoading &&
  419. filtered.map((entity) => (
  420. <button
  421. key={entity.entity_id}
  422. type="button"
  423. onClick={() => selectEntity(entity)}
  424. className={`w-full text-left px-3 py-2 text-sm transition-colors ${
  425. entity.entity_id === entityId
  426. ? 'bg-bambu-green/20 text-bambu-green'
  427. : 'text-white hover:bg-bambu-dark'
  428. }`}
  429. >
  430. <div className="font-medium">{entity.friendly_name}</div>
  431. <div className="text-xs text-bambu-gray">
  432. {entity.entity_id}
  433. {entity.state !== null && ` — ${entity.state}`}
  434. {entity.unit_of_measurement ? ` ${entity.unit_of_measurement}` : ''}
  435. </div>
  436. </button>
  437. ))}
  438. </div>
  439. </div>
  440. <div>
  441. <label className="block text-sm text-bambu-gray mb-1" htmlFor="location-ha-sensor-name">
  442. {t('haSensors.name')}
  443. </label>
  444. <input
  445. id="location-ha-sensor-name"
  446. type="text"
  447. value={name}
  448. onChange={(e) => setName(e.target.value)}
  449. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  450. />
  451. </div>
  452. {entityId && (
  453. <div>
  454. <label className="block text-sm text-bambu-gray mb-1">{t('haSensors.alertWhen')}</label>
  455. {kind === 'binary' ? (
  456. <select
  457. value={alertState}
  458. onChange={(e) => setAlertState(e.target.value as 'on' | 'off' | '')}
  459. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  460. >
  461. <option value="">{t('haSensors.alertNever')}</option>
  462. <option value="on">{stateLabel('on')}</option>
  463. <option value="off">{stateLabel('off')}</option>
  464. </select>
  465. ) : categoryFor(deviceClass) === 'battery' ? (
  466. <div>
  467. <span className="block text-xs text-bambu-gray mb-1">
  468. {t('haSensors.alertBelow')} {unit ?? ''}
  469. </span>
  470. <input
  471. type="number"
  472. step="any"
  473. value={alertBelow}
  474. onChange={(e) => setAlertBelow(e.target.value)}
  475. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  476. />
  477. </div>
  478. ) : (
  479. <div className="grid grid-cols-2 gap-3">
  480. <div>
  481. <span className="block text-xs text-bambu-gray mb-1">
  482. {t('haSensors.alertAbove')} {unit ?? ''}
  483. </span>
  484. <input
  485. type="number"
  486. step="any"
  487. value={alertAbove}
  488. onChange={(e) => setAlertAbove(e.target.value)}
  489. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  490. />
  491. </div>
  492. <div>
  493. <span className="block text-xs text-bambu-gray mb-1">
  494. {t('haSensors.alertBelow')} {unit ?? ''}
  495. </span>
  496. <input
  497. type="number"
  498. step="any"
  499. value={alertBelow}
  500. onChange={(e) => setAlertBelow(e.target.value)}
  501. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
  502. />
  503. </div>
  504. </div>
  505. )}
  506. <p className="mt-1 text-xs text-bambu-gray">{t('haSensors.alertHint')}</p>
  507. </div>
  508. )}
  509. <label className="flex items-center gap-3 cursor-pointer">
  510. <input
  511. type="checkbox"
  512. checked={showOnCard}
  513. onChange={(e) => setShowOnCard(e.target.checked)}
  514. className="w-4 h-4"
  515. />
  516. <span className="text-sm text-white">{t('locationHaSensors.showOnCard')}</span>
  517. </label>
  518. <label className="flex items-center gap-3 cursor-pointer">
  519. <input
  520. type="checkbox"
  521. checked={notifyOnAlert}
  522. onChange={(e) => setNotifyOnAlert(e.target.checked)}
  523. disabled={!hasAlertCondition}
  524. className="w-4 h-4"
  525. />
  526. <span className={`text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
  527. {t('haSensors.notifyOnAlert')}
  528. </span>
  529. </label>
  530. <div className="flex items-center justify-between pt-2">
  531. {isEditing ? (
  532. <Button type="button" variant="danger" onClick={() => deleteMutation.mutate()} disabled={isPending}>
  533. {t('common.delete')}
  534. </Button>
  535. ) : (
  536. <span />
  537. )}
  538. <div className="flex items-center gap-2">
  539. <Button type="button" variant="secondary" onClick={onClose} disabled={isPending}>
  540. {t('common.cancel')}
  541. </Button>
  542. <Button type="submit" disabled={isPending || (!haConfigured && !isEditing)}>
  543. {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
  544. {t('common.save')}
  545. </Button>
  546. </div>
  547. </div>
  548. </form>
  549. </div>
  550. </div>
  551. {showOverwriteConfirm && overwriteTarget && (
  552. <ConfirmModal
  553. title={t('locationHaSensors.overwriteConfirm.title')}
  554. message={t(
  555. `locationHaSensors.overwriteConfirm.message${
  556. categoryFor(overwriteTarget.device_class) === 'humidity'
  557. ? 'Humidity'
  558. : categoryFor(overwriteTarget.device_class) === 'battery'
  559. ? 'Battery'
  560. : 'Temperature'
  561. }`,
  562. {
  563. location: currentLocation?.name ?? '',
  564. name: overwriteTarget.name,
  565. }
  566. )}
  567. variant="warning"
  568. overlayZIndex="z-[110]"
  569. isLoading={overwriteMutation.isPending}
  570. onConfirm={() => overwriteMutation.mutate()}
  571. onCancel={() => {
  572. setShowOverwriteConfirm(false);
  573. setOverwriteTarget(null);
  574. }}
  575. />
  576. )}
  577. {showAutoAddConfirm && autoAddCandidates.length > 0 && (
  578. <ConfirmModal
  579. title={t('locationHaSensors.autoAdd.confirmTitle')}
  580. message={t('locationHaSensors.autoAdd.confirmMessage')}
  581. overlayZIndex="z-[110]"
  582. isLoading={autoAddMutation.isPending}
  583. confirmDisabled={!autoAddCandidates.some((e) => autoAddSelected[e.entity_id])}
  584. onConfirm={() => autoAddMutation.mutate()}
  585. // "Cancel" here still saves the sensor the user picked — it only
  586. // skips the siblings — so the button needs to say what it does
  587. // rather than implying the whole thing is being abandoned.
  588. cancelText={t('locationHaSensors.autoAdd.onlyThisOne')}
  589. onCancel={() => {
  590. setShowAutoAddConfirm(false);
  591. setAutoAddCandidates([]);
  592. setAutoAddSelected({});
  593. saveMutation.mutate();
  594. }}
  595. >
  596. <div className="space-y-2">
  597. {autoAddCandidates.map((entity) => (
  598. <label key={entity.entity_id} className="flex items-center gap-3 cursor-pointer">
  599. <input
  600. type="checkbox"
  601. checked={autoAddSelected[entity.entity_id] ?? true}
  602. onChange={(e) =>
  603. setAutoAddSelected((prev) => ({ ...prev, [entity.entity_id]: e.target.checked }))
  604. }
  605. className="w-4 h-4"
  606. />
  607. <span className="text-sm text-white">
  608. {entity.friendly_name}
  609. <span className="text-bambu-gray"> — {entity.entity_id}</span>
  610. </span>
  611. </label>
  612. ))}
  613. </div>
  614. </ConfirmModal>
  615. )}
  616. {showAddLocationModal && (
  617. <LocationsModal
  618. open={showAddLocationModal}
  619. onClose={() => setShowAddLocationModal(false)}
  620. onPickLocation={(id) => {
  621. setLocationId(id);
  622. setShowAddLocationModal(false);
  623. }}
  624. startCreating
  625. />
  626. )}
  627. </>
  628. );
  629. }