| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412 |
- import { useState, useEffect } from 'react';
- import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
- import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock } from 'lucide-react';
- import { api } from '../api/client';
- import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
- import { Button } from './Button';
- interface AddSmartPlugModalProps {
- plug?: SmartPlug | null;
- onClose: () => void;
- }
- export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
- const queryClient = useQueryClient();
- const isEditing = !!plug;
- const [name, setName] = useState(plug?.name || '');
- const [ipAddress, setIpAddress] = useState(plug?.ip_address || '');
- const [username, setUsername] = useState(plug?.username || '');
- const [password, setPassword] = useState(plug?.password || '');
- const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
- const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
- const [error, setError] = useState<string | null>(null);
- // Power alert settings
- const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);
- const [powerAlertHigh, setPowerAlertHigh] = useState<string>(plug?.power_alert_high?.toString() || '');
- const [powerAlertLow, setPowerAlertLow] = useState<string>(plug?.power_alert_low?.toString() || '');
- // Schedule settings
- const [scheduleEnabled, setScheduleEnabled] = useState(plug?.schedule_enabled || false);
- const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
- const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
- // Fetch printers for linking
- const { data: printers } = useQuery({
- queryKey: ['printers'],
- queryFn: api.getPrinters,
- });
- // Fetch existing plugs to check for conflicts
- const { data: existingPlugs } = useQuery({
- queryKey: ['smart-plugs'],
- queryFn: api.getSmartPlugs,
- });
- // Close on Escape key
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose();
- };
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [onClose]);
- // Test connection mutation
- const testMutation = useMutation({
- mutationFn: () => api.testSmartPlugConnection(ipAddress, username || null, password || null),
- onSuccess: (result) => {
- setTestResult(result);
- setError(null);
- // Auto-fill name from device if empty
- if (!name && result.device_name) {
- setName(result.device_name);
- }
- },
- onError: (err: Error) => {
- setTestResult(null);
- setError(err.message);
- },
- });
- // Create mutation
- const createMutation = useMutation({
- mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
- onClose();
- },
- onError: (err: Error) => {
- setError(err.message);
- },
- });
- // Update mutation
- const updateMutation = useMutation({
- mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
- onClose();
- },
- onError: (err: Error) => {
- setError(err.message);
- },
- });
- // Filter out printers that already have a plug assigned (except current plug's printer)
- const availablePrinters = printers?.filter(p => {
- const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id);
- return !hasPlug;
- });
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- setError(null);
- if (!name.trim()) {
- setError('Name is required');
- return;
- }
- if (!ipAddress.trim()) {
- setError('IP address is required');
- return;
- }
- const data = {
- name: name.trim(),
- ip_address: ipAddress.trim(),
- username: username.trim() || null,
- password: password.trim() || null,
- printer_id: printerId,
- // Power alerts
- power_alert_enabled: powerAlertEnabled,
- power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,
- power_alert_low: powerAlertLow ? parseFloat(powerAlertLow) : null,
- // Schedule
- schedule_enabled: scheduleEnabled,
- schedule_on_time: scheduleOnTime || null,
- schedule_off_time: scheduleOffTime || null,
- };
- if (isEditing) {
- updateMutation.mutate(data);
- } else {
- createMutation.mutate(data);
- }
- };
- const isPending = createMutation.isPending || updateMutation.isPending;
- return (
- <div
- className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
- onClick={onClose}
- >
- <div
- className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md"
- onClick={(e) => e.stopPropagation()}
- >
- {/* Header */}
- <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
- <h2 className="text-lg font-semibold text-white">
- {isEditing ? 'Edit Smart Plug' : 'Add Smart Plug'}
- </h2>
- <button
- onClick={onClose}
- className="text-bambu-gray hover:text-white transition-colors"
- >
- <X className="w-5 h-5" />
- </button>
- </div>
- {/* Form */}
- <form onSubmit={handleSubmit} className="p-6 space-y-4">
- {error && (
- <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
- {error}
- </div>
- )}
- {/* IP Address */}
- <div>
- <label className="block text-sm text-bambu-gray mb-1">IP Address *</label>
- <div className="flex gap-2">
- <input
- type="text"
- value={ipAddress}
- onChange={(e) => {
- setIpAddress(e.target.value);
- setTestResult(null);
- }}
- placeholder="192.168.1.100"
- className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- />
- <Button
- type="button"
- variant="secondary"
- onClick={() => testMutation.mutate()}
- disabled={!ipAddress.trim() || testMutation.isPending}
- >
- {testMutation.isPending ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : (
- <Wifi className="w-4 h-4" />
- )}
- Test
- </Button>
- </div>
- </div>
- {/* Test Result */}
- {testResult && (
- <div className={`p-3 rounded-lg flex items-center gap-2 ${
- testResult.success
- ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'
- : 'bg-red-500/20 border border-red-500/50 text-red-400'
- }`}>
- {testResult.success ? (
- <>
- <CheckCircle className="w-5 h-5" />
- <div>
- <p className="font-medium">Connected!</p>
- <p className="text-sm opacity-80">
- {testResult.device_name && `Device: ${testResult.device_name} - `}
- State: {testResult.state}
- </p>
- </div>
- </>
- ) : (
- <>
- <WifiOff className="w-5 h-5" />
- <span>Connection failed</span>
- </>
- )}
- </div>
- )}
- {/* Name */}
- <div>
- <label className="block text-sm text-bambu-gray mb-1">Name *</label>
- <input
- type="text"
- value={name}
- onChange={(e) => setName(e.target.value)}
- placeholder="Living Room Plug"
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- />
- </div>
- {/* Authentication (optional) */}
- <div className="grid grid-cols-2 gap-3">
- <div>
- <label className="block text-sm text-bambu-gray mb-1">Username</label>
- <input
- type="text"
- value={username}
- onChange={(e) => setUsername(e.target.value)}
- placeholder="admin"
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- />
- </div>
- <div>
- <label className="block text-sm text-bambu-gray mb-1">Password</label>
- <input
- type="password"
- value={password}
- onChange={(e) => setPassword(e.target.value)}
- placeholder="********"
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- />
- </div>
- </div>
- <p className="text-xs text-bambu-gray -mt-2">
- Leave empty if your Tasmota device doesn't require authentication
- </p>
- {/* Link to Printer */}
- <div>
- <label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
- <select
- value={printerId ?? ''}
- onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- >
- <option value="">No printer (manual control only)</option>
- {availablePrinters?.map((p) => (
- <option key={p.id} value={p.id}>
- {p.name}
- </option>
- ))}
- </select>
- <p className="text-xs text-bambu-gray mt-1">
- Linking enables automatic on/off when prints start/complete
- </p>
- </div>
- {/* Power Alerts */}
- <div className="border-t border-bambu-dark-tertiary pt-4">
- <div className="flex items-center justify-between mb-3">
- <div className="flex items-center gap-2">
- <Bell className="w-4 h-4 text-bambu-green" />
- <span className="text-white font-medium">Power Alerts</span>
- </div>
- <label className="relative inline-flex items-center cursor-pointer">
- <input
- type="checkbox"
- checked={powerAlertEnabled}
- onChange={(e) => setPowerAlertEnabled(e.target.checked)}
- className="sr-only peer"
- />
- <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
- </label>
- </div>
- {powerAlertEnabled && (
- <div className="space-y-3">
- <div className="grid grid-cols-2 gap-3">
- <div>
- <label className="block text-sm text-bambu-gray mb-1">Alert if above (W)</label>
- <input
- type="number"
- value={powerAlertHigh}
- onChange={(e) => setPowerAlertHigh(e.target.value)}
- placeholder="e.g. 200"
- min="0"
- max="5000"
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- />
- </div>
- <div>
- <label className="block text-sm text-bambu-gray mb-1">Alert if below (W)</label>
- <input
- type="number"
- value={powerAlertLow}
- onChange={(e) => setPowerAlertLow(e.target.value)}
- placeholder="e.g. 10"
- min="0"
- max="5000"
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- />
- </div>
- </div>
- <p className="text-xs text-bambu-gray">
- Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.
- </p>
- </div>
- )}
- </div>
- {/* Schedule */}
- <div className="border-t border-bambu-dark-tertiary pt-4">
- <div className="flex items-center justify-between mb-3">
- <div className="flex items-center gap-2">
- <Clock className="w-4 h-4 text-bambu-green" />
- <span className="text-white font-medium">Daily Schedule</span>
- </div>
- <label className="relative inline-flex items-center cursor-pointer">
- <input
- type="checkbox"
- checked={scheduleEnabled}
- onChange={(e) => setScheduleEnabled(e.target.checked)}
- className="sr-only peer"
- />
- <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
- </label>
- </div>
- {scheduleEnabled && (
- <div className="space-y-3">
- <div className="grid grid-cols-2 gap-3">
- <div>
- <label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
- <input
- type="time"
- value={scheduleOnTime}
- onChange={(e) => setScheduleOnTime(e.target.value)}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- />
- </div>
- <div>
- <label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
- <input
- type="time"
- value={scheduleOffTime}
- onChange={(e) => setScheduleOffTime(e.target.value)}
- className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
- />
- </div>
- </div>
- <p className="text-xs text-bambu-gray">
- Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
- </p>
- </div>
- )}
- </div>
- {/* Actions */}
- <div className="flex gap-3 pt-2">
- <Button
- type="button"
- variant="secondary"
- onClick={onClose}
- className="flex-1"
- >
- Cancel
- </Button>
- <Button
- type="submit"
- disabled={isPending}
- className="flex-1"
- >
- {isPending ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : (
- <Save className="w-4 h-4" />
- )}
- {isEditing ? 'Save' : 'Add'}
- </Button>
- </div>
- </form>
- </div>
- </div>
- );
- }
|