| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670 |
- import { useEffect, useMemo, useRef, useState } from 'react';
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
- import { Gauge, Loader2, Plus, Save, Search, X } from 'lucide-react';
- import { useTranslation } from 'react-i18next';
- import { api } from '../api/client';
- import type { HADisplayEntity, LocationHASensor, StorageLocation } from '../api/client';
- import { Button } from './Button';
- import { ConfirmModal } from './ConfirmModal';
- import { LocationsModal } from './LocationsModal';
- import { useToast } from '../contexts/ToastContext';
- import { loadLocationSensorDefaults } from '../utils/locationSensorDefaults';
- import { HA_SENSOR_BINARY_LABELS } from '../utils/haSensorDisplay';
- interface Props {
- sensor?: LocationHASensor | null;
- locations: StorageLocation[];
- onClose: () => void;
- }
- type SensorCategory = 'temperature' | 'humidity' | 'battery';
- // Doubles as the picker's filter (see `entities` below): an entity with no
- // category cannot be bound to a location at all.
- //
- // "moisture" is deliberately absent. It is Home Assistant's binary wet/dry
- // class, not a humidity percentage, and mapping it here made it a humidity
- // sensor everywhere downstream — it landed in the percent-formatted humidity
- // column rendering "wet", blocked a real humidity sensor on the same location
- // via the one-per-category rule, and could never take the seeded thresholds
- // because the schema rejects alert_above/alert_below for kind="binary".
- // A storage location wants a hygrometer, not a leak detector.
- function categoryFor(deviceClass: string | null): SensorCategory | null {
- if (deviceClass === 'temperature') return 'temperature';
- if (deviceClass === 'humidity') return 'humidity';
- if (deviceClass === 'battery') return 'battery';
- return null;
- }
- const CATEGORY_SUFFIXES: Record<SensorCategory, string> = {
- temperature: 'temperature',
- humidity: 'humidity',
- battery: 'battery',
- };
- function findSiblingEntities(
- entityId: string,
- category: SensorCategory,
- candidates: HADisplayEntity[]
- ): HADisplayEntity[] {
- const suffix = CATEGORY_SUFFIXES[category];
- const lower = entityId.toLowerCase();
- if (!lower.endsWith(suffix)) return [];
- const prefix = entityId.slice(0, entityId.length - suffix.length);
- const siblings: HADisplayEntity[] = [];
- (Object.keys(CATEGORY_SUFFIXES) as SensorCategory[]).forEach((otherCategory) => {
- if (otherCategory === category) return;
- const candidateId = `${prefix}${CATEGORY_SUFFIXES[otherCategory]}`.toLowerCase();
- const match = candidates.find((c) => c.entity_id.toLowerCase() === candidateId);
- if (match) siblings.push(match);
- });
- return siblings;
- }
- export function LocationHASensorModal({ sensor, locations, onClose }: Props) {
- const { t } = useTranslation();
- const queryClient = useQueryClient();
- const { showToast } = useToast();
- const isEditing = !!sensor;
- const [locationId, setLocationId] = useState<number | ''>(sensor?.location_id ?? locations[0]?.id ?? '');
- const [entityId, setEntityId] = useState(sensor?.entity_id ?? '');
- const [kind, setKind] = useState<'binary' | 'numeric'>(sensor?.kind ?? 'numeric');
- const [deviceClass, setDeviceClass] = useState<string | null>(sensor?.device_class ?? null);
- const [unit, setUnit] = useState<string | null>(sensor?.unit ?? null);
- const [name, setName] = useState(sensor?.name ?? '');
- // Tracks the last name we auto-filled (or the initial saved name), so
- // switching to a different entity can follow along with the new friendly
- // name — but only while the field still holds what we put there. A name
- // the user typed themselves is never overwritten by an entity change.
- const autoFilledNameRef = useRef(sensor?.name ?? '');
- const [alertState, setAlertState] = useState<'on' | 'off' | ''>(sensor?.alert_state ?? '');
- const [alertAbove, setAlertAbove] = useState(sensor?.alert_above?.toString() ?? '');
- const [alertBelow, setAlertBelow] = useState(sensor?.alert_below?.toString() ?? '');
- const [notifyOnAlert, setNotifyOnAlert] = useState(sensor?.notify_on_alert ?? false);
- const [showOnCard, setShowOnCard] = useState(sensor?.show_on_card ?? true);
- const [search, setSearch] = useState('');
- const [error, setError] = useState<string | null>(null);
- const [showAddLocationModal, setShowAddLocationModal] = useState(false);
- const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
- const [overwriteTarget, setOverwriteTarget] = useState<LocationHASensor | null>(null);
- const [showAutoAddConfirm, setShowAutoAddConfirm] = useState(false);
- const [autoAddCandidates, setAutoAddCandidates] = useState<HADisplayEntity[]>([]);
- const [autoAddSelected, setAutoAddSelected] = useState<Record<string, boolean>>({});
- const { data: settings } = useQuery({
- queryKey: ['settings'],
- queryFn: api.getSettings,
- });
- const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
- const { data: rawEntities, isLoading: entitiesLoading, error: entitiesError } = useQuery({
- queryKey: ['bindableLocationHAEntities'],
- queryFn: () => api.getBindableLocationHAEntities(),
- enabled: haConfigured,
- });
- const entities = useMemo(
- () => (rawEntities ?? []).filter((e) => categoryFor(e.device_class) !== null),
- [rawEntities]
- );
- const { data: allLocationSensors } = useQuery({
- queryKey: ['locationHaSensors'],
- queryFn: () => api.getLocationHASensors(),
- });
- const selected = entities.find((e) => e.entity_id === entityId);
- const filtered = useMemo(() => {
- const needle = search.trim().toLowerCase();
- const all = entities ?? [];
- const matches = needle
- ? all.filter((e) => e.entity_id.toLowerCase().includes(needle) || e.friendly_name.toLowerCase().includes(needle))
- : all;
- if (!selected || matches[0]?.entity_id === selected.entity_id) return matches;
- return [selected, ...matches.filter((e) => e.entity_id !== selected.entity_id)];
- }, [entities, search, selected]);
- const selectEntity = (entity: HADisplayEntity) => {
- setEntityId(entity.entity_id);
- setDeviceClass(entity.device_class);
- // Sliced like the name below: the unit is snapshotted from Home Assistant,
- // not typed by the user, so an oversized one must not come back as a 422
- // on a field the form never showed them. The column is String(16).
- setUnit(entity.unit_of_measurement?.slice(0, 16) ?? null);
- const nextKind = entity.domain === 'binary_sensor' ? 'binary' : 'numeric';
- setKind(nextKind);
- if (nextKind === 'numeric') setAlertState('');
- else {
- setAlertAbove('');
- setAlertBelow('');
- }
- // Sliced to the column width: Home Assistant friendly names have no length
- // limit, and a long one would come back as a Pydantic error on a field the
- // user did not type into. Follows the entity picker as long as the name
- // still matches what we last auto-filled — a name the user typed
- // themselves is left alone even when they pick a different entity.
- if (name === autoFilledNameRef.current) {
- const nextName = entity.friendly_name.slice(0, 100);
- setName(nextName);
- autoFilledNameRef.current = nextName;
- }
- if (!isEditing) {
- const category = categoryFor(entity.device_class);
- if (category) {
- const defaults = loadLocationSensorDefaults(settings?.location_sensor_alert_defaults)[category];
- if (nextKind === 'numeric') {
- setAlertAbove(category === 'battery' ? '' : defaults.alertAbove);
- setAlertBelow(defaults.alertBelow);
- }
- setNotifyOnAlert(defaults.notifyOnAlert);
- setShowOnCard(defaults.showOnCard);
- }
- }
- };
- const invalidate = () => {
- queryClient.invalidateQueries({ queryKey: ['locationHaSensors'] });
- queryClient.invalidateQueries({ queryKey: ['locationHaSensorReadings'] });
- };
- const buildPrimaryPayload = () => ({
- name: name.trim(),
- entity_id: entityId,
- kind,
- device_class: deviceClass,
- unit,
- alert_state: kind === 'binary' && alertState ? alertState : null,
- alert_above:
- kind === 'numeric' && categoryFor(deviceClass) !== 'battery' && alertAbove !== '' ? Number(alertAbove) : null,
- alert_below: kind === 'numeric' && alertBelow !== '' ? Number(alertBelow) : null,
- notify_on_alert: notifyOnAlert,
- show_on_card: showOnCard,
- });
- const saveMutation = useMutation({
- mutationFn: () => {
- const payload = buildPrimaryPayload();
- return isEditing
- ? api.updateLocationHASensor(sensor.id, payload)
- : api.createLocationHASensor({ ...payload, location_id: Number(locationId) });
- },
- onSuccess: () => {
- invalidate();
- showToast(isEditing ? t('locationHaSensors.toast.updated') : t('locationHaSensors.toast.created'), 'success');
- onClose();
- },
- onError: (err: Error) => setError(err.message),
- });
- const autoAddMutation = useMutation({
- mutationFn: async () => {
- const targetLocationId = Number(locationId);
- await api.createLocationHASensor({ ...buildPrimaryPayload(), location_id: targetLocationId });
- const defaults = loadLocationSensorDefaults(settings?.location_sensor_alert_defaults);
- const chosen = autoAddCandidates.filter((entity) => autoAddSelected[entity.entity_id]);
- for (const entity of chosen) {
- const categoryDefaults = categoryFor(entity.device_class);
- const d = categoryDefaults ? defaults[categoryDefaults] : null;
- await api.createLocationHASensor({
- name: entity.friendly_name.slice(0, 100),
- entity_id: entity.entity_id,
- kind: entity.domain === 'binary_sensor' ? 'binary' : 'numeric',
- device_class: entity.device_class,
- unit: entity.unit_of_measurement?.slice(0, 16) ?? null,
- alert_state: null,
- alert_above: categoryDefaults !== 'battery' && d && d.alertAbove !== '' ? Number(d.alertAbove) : null,
- alert_below: d && d.alertBelow !== '' ? Number(d.alertBelow) : null,
- notify_on_alert: d?.notifyOnAlert ?? false,
- show_on_card: d?.showOnCard ?? showOnCard,
- location_id: targetLocationId,
- });
- }
- return chosen;
- },
- onSuccess: (chosen) => {
- invalidate();
- setShowAutoAddConfirm(false);
- if (chosen.length > 0) {
- showToast(t('locationHaSensors.autoAdd.added', { names: chosen.map((e) => e.entity_id).join(', ') }), 'success');
- } else {
- showToast(t('locationHaSensors.toast.created'), 'success');
- }
- onClose();
- },
- onError: (err: Error) => {
- invalidate();
- setShowAutoAddConfirm(false);
- setError(err.message);
- },
- });
- const deleteMutation = useMutation({
- mutationFn: () => api.deleteLocationHASensor(sensor!.id),
- onSuccess: () => {
- invalidate();
- showToast(t('locationHaSensors.toast.deleted'), 'success');
- onClose();
- },
- onError: (err: Error) => setError(err.message),
- });
- const overwriteMutation = useMutation({
- // PATCH the existing row onto the new entity instead of deleting it and
- // creating a replacement: a delete-then-create left a window where, if
- // the create failed, the old binding was already gone and nothing had
- // taken its place. A single PATCH either lands or leaves the original
- // binding untouched.
- mutationFn: () => api.updateLocationHASensor(overwriteTarget!.id, buildPrimaryPayload()),
- onSuccess: () => {
- invalidate();
- setShowOverwriteConfirm(false);
- setOverwriteTarget(null);
- showToast(t('locationHaSensors.toast.updated'), 'success');
- onClose();
- },
- onError: (err: Error) => {
- setShowOverwriteConfirm(false);
- setOverwriteTarget(null);
- setError(err.message);
- },
- });
- const hasAlertCondition = kind === 'binary' ? alertState !== '' : alertAbove !== '' || alertBelow !== '';
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- setError(null);
- if (!entityId) return setError(t('haSensors.error.pickEntity'));
- if (!name.trim()) return setError(t('haSensors.error.nameRequired'));
- if (locationId === '') return setError(t('locationHaSensors.error.locationRequired'));
- if (notifyOnAlert && !hasAlertCondition) {
- return setError(t('haSensors.error.alertRequired'));
- }
- if (!isEditing) {
- const locationSensors = (allLocationSensors ?? []).filter((s) => s.location_id === Number(locationId));
- const category = categoryFor(deviceClass);
- if (category) {
- const conflicting = locationSensors.find((s) => categoryFor(s.device_class) === category);
- if (conflicting) {
- setOverwriteTarget(conflicting);
- setShowOverwriteConfirm(true);
- return;
- }
- }
- if (locationSensors.length === 0 && category) {
- const siblings = findSiblingEntities(entityId, category, entities);
- if (siblings.length > 0) {
- setAutoAddCandidates(siblings);
- setAutoAddSelected(Object.fromEntries(siblings.map((s) => [s.entity_id, true])));
- setShowAutoAddConfirm(true);
- return;
- }
- showToast(t('locationHaSensors.autoAdd.noneFound'), 'info');
- }
- }
- saveMutation.mutate();
- };
- const alertLabels = HA_SENSOR_BINARY_LABELS[deviceClass ?? ''];
- const stateLabel = (which: 'on' | 'off') => {
- const key = alertLabels?.[which] ?? which;
- return t(`haSensors.states.${key}`, { defaultValue: key });
- };
- const isPending =
- saveMutation.isPending || deleteMutation.isPending || overwriteMutation.isPending || autoAddMutation.isPending;
- const currentLocation = locations.find((l) => l.id === Number(locationId));
- // Escape closes the modal, but not while a mutation is mid-flight — a
- // stray keypress landing between the overwrite PATCH's dispatch and its
- // response must not drop the user out with an orphaned request.
- useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- if (e.key === 'Escape' && !isPending) onClose();
- };
- window.addEventListener('keydown', onKey);
- return () => window.removeEventListener('keydown', onKey);
- }, [onClose, isPending]);
- return (
- <>
- <div
- className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
- onClick={() => {
- if (!isPending) onClose();
- }}
- >
- <div
- className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto"
- onClick={(e) => e.stopPropagation()}
- >
- <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
- <div className="flex items-center gap-3">
- <div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
- <Gauge className="w-5 h-5" />
- </div>
- <h2 className="text-lg font-semibold text-white">
- {isEditing ? t('locationHaSensors.editTitle') : t('locationHaSensors.addTitle')}
- </h2>
- </div>
- <button
- onClick={onClose}
- disabled={isPending}
- className="text-bambu-gray hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
- >
- <X className="w-5 h-5" />
- </button>
- </div>
- <form onSubmit={handleSubmit} className="p-6 space-y-4">
- {error && (
- <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">
- {error}
- </div>
- )}
- <div>
- <label className="block text-sm text-bambu-gray mb-1">{t('locationHaSensors.location')}</label>
- {isEditing ? (
- <div className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white">
- {locations.find((l) => l.id === sensor.location_id)?.name ?? t('locationHaSensors.unknownLocation')}
- </div>
- ) : (
- <div className="flex items-center gap-2">
- <select
- value={locationId}
- onChange={(e) => setLocationId(e.target.value === '' ? '' : Number(e.target.value))}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
- >
- {locations.map((l) => (
- <option key={l.id} value={l.id}>
- {l.name}
- </option>
- ))}
- </select>
- <button
- type="button"
- onClick={() => setShowAddLocationModal(true)}
- className="p-2 rounded-lg bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white transition-colors shrink-0"
- title={t('locations.add')}
- aria-label={t('locations.add')}
- >
- <Plus className="w-4 h-4" />
- </button>
- </div>
- )}
- </div>
- {!haConfigured && (
- <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">
- {t('smartPlugs.haNotConfigured')}{' '}
- <span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
- </div>
- )}
- <div>
- <label className={`block text-sm text-bambu-gray mb-1 ${haConfigured ? '' : 'opacity-50'}`}>
- {t('haSensors.entity')}
- </label>
- {entitiesError && (
- <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">
- {(entitiesError as Error).message}
- </div>
- )}
- <div className="relative mb-2">
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
- <input
- type="text"
- value={search}
- onChange={(e) => setSearch(e.target.value)}
- placeholder={t('haSensors.searchPlaceholder')}
- disabled={!haConfigured}
- 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"
- />
- </div>
- <div
- className={`max-h-44 overflow-y-auto rounded-lg border border-bambu-dark-tertiary ${
- haConfigured ? '' : 'opacity-50'
- }`}
- >
- {entityId && !selected && (
- <div className="px-3 py-2 text-sm bg-bambu-green/10 text-bambu-green border-b border-bambu-dark-tertiary">
- {t('locationHaSensors.currentlyBound', { entity: entityId })}
- </div>
- )}
- {!haConfigured && <div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>}
- {haConfigured && entitiesLoading && (
- <div className="flex items-center gap-2 p-3 text-sm text-bambu-gray">
- <Loader2 className="w-4 h-4 animate-spin" />
- {t('common.loading')}
- </div>
- )}
- {haConfigured && !entitiesLoading && filtered.length === 0 && (
- <div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
- )}
- {haConfigured &&
- !entitiesLoading &&
- filtered.map((entity) => (
- <button
- key={entity.entity_id}
- type="button"
- onClick={() => selectEntity(entity)}
- className={`w-full text-left px-3 py-2 text-sm transition-colors ${
- entity.entity_id === entityId
- ? 'bg-bambu-green/20 text-bambu-green'
- : 'text-white hover:bg-bambu-dark'
- }`}
- >
- <div className="font-medium">{entity.friendly_name}</div>
- <div className="text-xs text-bambu-gray">
- {entity.entity_id}
- {entity.state !== null && ` — ${entity.state}`}
- {entity.unit_of_measurement ? ` ${entity.unit_of_measurement}` : ''}
- </div>
- </button>
- ))}
- </div>
- </div>
- <div>
- <label className="block text-sm text-bambu-gray mb-1" htmlFor="location-ha-sensor-name">
- {t('haSensors.name')}
- </label>
- <input
- id="location-ha-sensor-name"
- type="text"
- value={name}
- onChange={(e) => setName(e.target.value)}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
- />
- </div>
- {entityId && (
- <div>
- <label className="block text-sm text-bambu-gray mb-1">{t('haSensors.alertWhen')}</label>
- {kind === 'binary' ? (
- <select
- value={alertState}
- onChange={(e) => setAlertState(e.target.value as 'on' | 'off' | '')}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
- >
- <option value="">{t('haSensors.alertNever')}</option>
- <option value="on">{stateLabel('on')}</option>
- <option value="off">{stateLabel('off')}</option>
- </select>
- ) : categoryFor(deviceClass) === 'battery' ? (
- <div>
- <span className="block text-xs text-bambu-gray mb-1">
- {t('haSensors.alertBelow')} {unit ?? ''}
- </span>
- <input
- type="number"
- step="any"
- value={alertBelow}
- onChange={(e) => setAlertBelow(e.target.value)}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
- />
- </div>
- ) : (
- <div className="grid grid-cols-2 gap-3">
- <div>
- <span className="block text-xs text-bambu-gray mb-1">
- {t('haSensors.alertAbove')} {unit ?? ''}
- </span>
- <input
- type="number"
- step="any"
- value={alertAbove}
- onChange={(e) => setAlertAbove(e.target.value)}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
- />
- </div>
- <div>
- <span className="block text-xs text-bambu-gray mb-1">
- {t('haSensors.alertBelow')} {unit ?? ''}
- </span>
- <input
- type="number"
- step="any"
- value={alertBelow}
- onChange={(e) => setAlertBelow(e.target.value)}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
- />
- </div>
- </div>
- )}
- <p className="mt-1 text-xs text-bambu-gray">{t('haSensors.alertHint')}</p>
- </div>
- )}
- <label className="flex items-center gap-3 cursor-pointer">
- <input
- type="checkbox"
- checked={showOnCard}
- onChange={(e) => setShowOnCard(e.target.checked)}
- className="w-4 h-4"
- />
- <span className="text-sm text-white">{t('locationHaSensors.showOnCard')}</span>
- </label>
- <label className="flex items-center gap-3 cursor-pointer">
- <input
- type="checkbox"
- checked={notifyOnAlert}
- onChange={(e) => setNotifyOnAlert(e.target.checked)}
- disabled={!hasAlertCondition}
- className="w-4 h-4"
- />
- <span className={`text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
- {t('haSensors.notifyOnAlert')}
- </span>
- </label>
- <div className="flex items-center justify-between pt-2">
- {isEditing ? (
- <Button type="button" variant="danger" onClick={() => deleteMutation.mutate()} disabled={isPending}>
- {t('common.delete')}
- </Button>
- ) : (
- <span />
- )}
- <div className="flex items-center gap-2">
- <Button type="button" variant="secondary" onClick={onClose} disabled={isPending}>
- {t('common.cancel')}
- </Button>
- <Button type="submit" disabled={isPending || (!haConfigured && !isEditing)}>
- {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
- {t('common.save')}
- </Button>
- </div>
- </div>
- </form>
- </div>
- </div>
- {showOverwriteConfirm && overwriteTarget && (
- <ConfirmModal
- title={t('locationHaSensors.overwriteConfirm.title')}
- message={t(
- `locationHaSensors.overwriteConfirm.message${
- categoryFor(overwriteTarget.device_class) === 'humidity'
- ? 'Humidity'
- : categoryFor(overwriteTarget.device_class) === 'battery'
- ? 'Battery'
- : 'Temperature'
- }`,
- {
- location: currentLocation?.name ?? '',
- name: overwriteTarget.name,
- }
- )}
- variant="warning"
- overlayZIndex="z-[110]"
- isLoading={overwriteMutation.isPending}
- onConfirm={() => overwriteMutation.mutate()}
- onCancel={() => {
- setShowOverwriteConfirm(false);
- setOverwriteTarget(null);
- }}
- />
- )}
- {showAutoAddConfirm && autoAddCandidates.length > 0 && (
- <ConfirmModal
- title={t('locationHaSensors.autoAdd.confirmTitle')}
- message={t('locationHaSensors.autoAdd.confirmMessage')}
- overlayZIndex="z-[110]"
- isLoading={autoAddMutation.isPending}
- confirmDisabled={!autoAddCandidates.some((e) => autoAddSelected[e.entity_id])}
- onConfirm={() => autoAddMutation.mutate()}
- // "Cancel" here still saves the sensor the user picked — it only
- // skips the siblings — so the button needs to say what it does
- // rather than implying the whole thing is being abandoned.
- cancelText={t('locationHaSensors.autoAdd.onlyThisOne')}
- onCancel={() => {
- setShowAutoAddConfirm(false);
- setAutoAddCandidates([]);
- setAutoAddSelected({});
- saveMutation.mutate();
- }}
- >
- <div className="space-y-2">
- {autoAddCandidates.map((entity) => (
- <label key={entity.entity_id} className="flex items-center gap-3 cursor-pointer">
- <input
- type="checkbox"
- checked={autoAddSelected[entity.entity_id] ?? true}
- onChange={(e) =>
- setAutoAddSelected((prev) => ({ ...prev, [entity.entity_id]: e.target.checked }))
- }
- className="w-4 h-4"
- />
- <span className="text-sm text-white">
- {entity.friendly_name}
- <span className="text-bambu-gray"> — {entity.entity_id}</span>
- </span>
- </label>
- ))}
- </div>
- </ConfirmModal>
- )}
- {showAddLocationModal && (
- <LocationsModal
- open={showAddLocationModal}
- onClose={() => setShowAddLocationModal(false)}
- onPickLocation={(id) => {
- setLocationId(id);
- setShowAddLocationModal(false);
- }}
- startCreating
- />
- )}
- </>
- );
- }
|