Files
bambuddy/frontend/src/components/AddNotificationModal.tsx
T
maziggy 6855d65d12 Let other applications send messages through the notification channels
POST /notifications/app-message delivers an app's message to every channel
with the new "Messages from connected apps" switch on (off by default),
through quiet hours, the digest and the log. API keys need the new "Send
notifications" permission, and their owner notifications:update; plain text,
http(s) links, 20 messages a minute per key. The electricity-price door and
this one now share one scoped-key check. /queue?batch=<id> opens and
highlights one batch order.
2026-09-27 12:45:50 +02:00

817 lines
40 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
import { api } from '../api/client';
import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
import { Button } from './Button';
import { Toggle } from './Toggle';
interface AddNotificationModalProps {
provider?: NotificationProvider | null;
onClose: () => void;
}
const PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'bark', 'callmebot', 'webhook', 'homeassistant'];
export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const isEditing = !!provider;
const [name, setName] = useState(provider?.name || '');
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'email');
const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
// Daily digest
const [dailyDigestEnabled, setDailyDigestEnabled] = useState(provider?.daily_digest_enabled || false);
const [dailyDigestTime, setDailyDigestTime] = useState(provider?.daily_digest_time || '08:00');
// Event toggles
const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false);
const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true);
const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);
const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);
const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
const [onBillingChargeFailed, setOnBillingChargeFailed] = useState(provider?.on_billing_charge_failed ?? true);
const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false);
const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
// Post-print outcome confirmation (#1898). Defaults ON — it only fires for
// prints that opted in per-job, so the toggle exists to mute a channel.
const [onPrintConfirmRequest, setOnPrintConfirmRequest] = useState(provider?.on_print_confirm_request ?? true);
const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_ha_sensor_alert ?? false);
const [onLocationHaSensorAlert, setOnLocationHaSensorAlert] = useState(
provider?.on_location_ha_sensor_alert ?? false
);
const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
const [onAppMessage, setOnAppMessage] = useState(provider?.on_app_message ?? false);
// Provider-specific config (scalar fields only — event_priorities is split out
// into its own state because it's an object, not a string).
const [config, setConfig] = useState<Record<string, string>>(
provider?.config
? Object.fromEntries(
Object.entries(provider.config)
.filter(([k]) => k !== 'event_priorities')
.map(([k, v]) => [k, String(v)]),
)
: {},
);
// Per-event ntfy priority (#990). Map of event key → 1-5. Persisted into
// config.event_priorities on save; only sent when the provider is ntfy.
const initialEventPriorities = (() => {
const raw = provider?.config?.event_priorities;
if (!raw || typeof raw !== 'object') return {} as Record<string, number>;
const out: Record<string, number> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
const n = Number(v);
if (Number.isInteger(n) && n >= 1 && n <= 5) out[k] = n;
}
return out;
})();
const [eventPriorities, setEventPriorities] = useState<Record<string, number>>(initialEventPriorities);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [error, setError] = useState<string | null>(null);
// Fetch printers for linking
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// 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 configuration mutation
const testMutation = useMutation({
mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config }),
onSuccess: (result) => {
setTestResult(result);
setError(null);
},
onError: (err: Error) => {
setTestResult({ success: false, message: err.message });
},
});
// Create mutation
const createMutation = useMutation({
mutationFn: (data: NotificationProviderCreate) => api.createNotificationProvider(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: NotificationProviderUpdate) => api.updateNotificationProvider(provider!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-providers'] });
onClose();
},
onError: (err: Error) => {
setError(err.message);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError(t('notifications.nameRequired'));
return;
}
// Validate provider-specific config
const requiredFields = getRequiredFields(providerType);
for (const field of requiredFields) {
if (!config[field.key]?.trim()) {
setError(t('notifications.fieldRequired', { field: field.label }));
return;
}
}
// HA custom service-data must be a JSON object (#1441)
if (providerType === 'homeassistant' && config.data?.trim()) {
let parsed: unknown;
try {
parsed = JSON.parse(config.data);
} catch {
setError(t('notifications.haDataInvalid'));
return;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
setError(t('notifications.haDataInvalid'));
return;
}
}
// Telegram forum topic must be a plain integer (#1518) — type="number"
// still lets "1e5" and "-" through, and Telegram would 400 on those.
if (providerType === 'telegram' && config.message_thread_id?.trim()) {
if (!/^\d+$/.test(config.message_thread_id.trim())) {
setError(t('notifications.telegramThreadIdInvalid'));
return;
}
}
const finalConfig: Record<string, unknown> =
providerType === 'ntfy' && Object.keys(eventPriorities).length > 0
? { ...config, event_priorities: eventPriorities }
: config;
const data = {
name: name.trim(),
provider_type: providerType,
config: finalConfig,
printer_id: printerId,
quiet_hours_enabled: quietHoursEnabled,
quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
// Daily digest
daily_digest_enabled: dailyDigestEnabled,
daily_digest_time: dailyDigestEnabled ? dailyDigestTime : null,
// Event toggles
on_print_start: onPrintStart,
on_print_complete: onPrintComplete,
on_print_failed: onPrintFailed,
on_print_stopped: onPrintStopped,
on_print_progress: onPrintProgress,
on_billing_charge_failed: onBillingChargeFailed,
on_printer_offline: onPrinterOffline,
on_printer_error: onPrinterError,
on_ai_failure_detection: onAiFailureDetection,
on_filament_low: onFilamentLow,
on_maintenance_due: onMaintenanceDue,
on_stock_reorder_alert: onStockReorderAlert,
on_stock_break_alert: onStockBreakAlert,
on_plate_clear_required: onPlateClearRequired,
on_print_confirm_request: onPrintConfirmRequest,
on_bed_cooled: onBedCooled,
on_ha_sensor_alert: onHaSensorAlert,
on_location_ha_sensor_alert: onLocationHaSensorAlert,
on_first_layer_complete: onFirstLayerComplete,
on_app_message: onAppMessage,
};
if (isEditing) {
updateMutation.mutate(data);
} else {
createMutation.mutate(data);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
// Get config fields for each provider type
const getConfigFields = (type: ProviderType) => {
switch (type) {
case 'callmebot':
return [
{ key: 'phone', label: 'Phone Number', placeholder: '+1234567890', type: 'text', required: true },
{ key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'text', required: true },
];
case 'ntfy':
return [
{ key: 'server', label: 'Server URL', placeholder: 'https://ntfy.sh', type: 'text', required: false },
{ key: 'topic', label: 'Topic', placeholder: 'my-bambuddy', type: 'text', required: true },
{ key: 'auth_token', label: 'Auth Token', placeholder: 'Optional authentication', type: 'password', required: false },
];
case 'pushover':
return [
{ key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'text', required: true },
{ key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'text', required: true },
{ key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false },
// Emergency priority (2) requires retry/expire — Pushover rejects the
// message otherwise. Only shown when priority is set to 2.
{
key: 'retry',
label: t('notifications.pushoverRetry'),
placeholder: '60',
type: 'number',
required: false,
showIf: (cfg: Record<string, string>) => cfg.priority === '2',
},
{
key: 'expire',
label: t('notifications.pushoverExpire'),
placeholder: '3600',
type: 'number',
required: false,
showIf: (cfg: Record<string, string>) => cfg.priority === '2',
},
];
case 'telegram':
return [
{ key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
{ key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },
// Optional forum topic (#1518). Left empty, Telegram posts to the
// group's General topic exactly as before.
{
key: 'message_thread_id',
label: t('notifications.telegramThreadId'),
placeholder: '123',
type: 'number',
required: false,
help: t('notifications.telegramThreadIdHelp'),
},
];
case 'email':
return [
{ key: 'smtp_server', label: 'SMTP Server', placeholder: 'smtp.gmail.com', type: 'text', required: true },
{ key: 'smtp_port', label: 'SMTP Port', placeholder: '587', type: 'number', required: false },
{ key: 'security', label: 'Security', type: 'select', required: false, options: [
{ value: 'starttls', label: 'STARTTLS (Port 587)' },
{ value: 'ssl', label: 'SSL/TLS (Port 465)' },
{ value: 'none', label: 'None (Port 25)' },
]},
{ key: 'auth_enabled', label: 'Authentication', type: 'select', required: false, options: [
{ value: 'true', label: 'Enabled' },
{ value: 'false', label: 'Disabled' },
]},
{ key: 'username', label: 'Username', placeholder: 'your@email.com', type: 'text', required: false },
{ key: 'password', label: 'Password', placeholder: 'App password', type: 'password', required: false },
{ key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true },
{ key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true },
];
case 'discord':
return [
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'text', required: true },
];
case 'webhook':
return [
{ key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'text', required: true },
{ key: 'payload_format', label: 'Payload Format', type: 'select', required: false, options: [
{ value: 'generic', label: 'Generic JSON' },
{ value: 'slack', label: 'Slack / Mattermost' },
]},
{ key: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false },
{ key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false, showIf: (cfg: Record<string, string>) => cfg.payload_format !== 'slack' },
{ key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false, showIf: (cfg: Record<string, string>) => cfg.payload_format !== 'slack' },
];
case 'homeassistant':
return [
{ key: 'service', label: 'Home Assistant Service', placeholder: 'notify.mobile_app_myphone', type: 'text', required: false },
{ key: 'data', label: 'Data (JSON, optional)', placeholder: '{"priority": "high", "ttl": 0, "channel": "3D Printing"}', type: 'textarea', required: false },
];
case 'bark':
return [
{ key: 'device_key', label: 'Device Key', placeholder: 'Your Bark device key', type: 'text', required: true },
{ key: 'server', label: 'Server URL', placeholder: 'https://api.day.app', type: 'text', required: false },
{ key: 'group', label: 'Group', placeholder: 'Bambuddy', type: 'text', required: false },
{ key: 'sound', label: 'Sound', placeholder: 'minuet', type: 'text', required: false },
{ key: 'level', label: 'Interruption Level', type: 'select', required: false, options: [
{ value: '', label: 'Default' },
{ value: 'active', label: 'Active' },
{ value: 'timeSensitive', label: 'Time Sensitive' },
{ value: 'critical', label: 'Critical (bypasses Silent/Focus)' },
{ value: 'passive', label: 'Passive (no sound)' },
]},
];
default:
return [];
}
};
const getRequiredFields = (type: ProviderType) => {
return getConfigFields(type).filter(f => f.required);
};
const configFields = getConfigFields(providerType);
return (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto"
onClick={onClose}
>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8 max-h-[90vh] overflow-y-auto"
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 ? t('notifications.editTitle') : t('notifications.addTitle')}
</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-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>
)}
{/* Name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('notifications.nameLabel')}</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('notifications.namePlaceholder')}
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>
{/* Provider Type */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('notifications.providerTypeLabel')}</label>
<select
value={providerType}
onChange={(e) => {
setProviderType(e.target.value as ProviderType);
setConfig({}); // Reset config when changing type
setTestResult(null);
}}
disabled={isEditing}
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 disabled:opacity-50"
>
{PROVIDER_VALUES.map((value) => (
<option key={value} value={value}>
{t(`notifications.providerTypes.${value}`, value)}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
{t(`notifications.providerDescriptions.${providerType}`, '')}
</p>
</div>
{/* Provider-specific configuration */}
<div className="space-y-3">
<p className="text-sm text-bambu-gray">{t('notifications.configuration')}</p>
{configFields
.filter((field) => !('showIf' in field) || (field as { showIf?: (cfg: Record<string, string>) => boolean }).showIf?.(config) !== false)
.map((field) => (
<div key={field.key}>
<label className="block text-sm text-bambu-gray mb-1">
{field.label} {field.required && '*'}
</label>
{field.type === 'select' && 'options' in field && field.options ? (
<select
value={config[field.key] || field.options[0]?.value || ''}
onChange={(e) => {
setConfig({ ...config, [field.key]: e.target.value });
setTestResult(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"
>
{field.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : field.type === 'textarea' ? (
<textarea
value={config[field.key] || ''}
onChange={(e) => {
setConfig({ ...config, [field.key]: e.target.value });
setTestResult(null);
}}
placeholder={field.placeholder}
rows={3}
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 font-mono text-sm"
/>
) : (
<input
type={field.type}
value={config[field.key] || ''}
onChange={(e) => {
setConfig({ ...config, [field.key]: e.target.value });
setTestResult(null);
}}
placeholder={field.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"
/>
)}
{'help' in field && (field as { help?: string }).help && (
<p className="text-xs text-bambu-gray mt-1">{(field as { help?: string }).help}</p>
)}
</div>
))}
</div>
{/* Test Button */}
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={() => {
setTestResult(null);
testMutation.mutate();
}}
disabled={testMutation.isPending || (getRequiredFields(providerType).length > 0 && !config[getRequiredFields(providerType)[0]?.key])}
className="flex-1"
>
{testMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
{t('notifications.testConfiguration')}
</Button>
</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-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 text-red-700 dark:text-red-400'
}`}>
{testResult.success ? (
<>
<CheckCircle className="w-5 h-5" />
<span>{testResult.message}</span>
</>
) : (
<>
<XCircle className="w-5 h-5" />
<span>{testResult.message}</span>
</>
)}
</div>
)}
{/* Link to Printer */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('notifications.printerFilter')}</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="">{t('notifications.allPrinters')}</option>
{printers?.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<p className="text-xs text-bambu-gray mt-1">
{t('notifications.onlyFromPrinter')}
</p>
</div>
{/* Quiet Hours */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm text-white">{t('notifications.quietHoursDnd')}</label>
<Toggle
checked={quietHoursEnabled}
onChange={setQuietHoursEnabled}
/>
</div>
{quietHoursEnabled && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-bambu-gray mb-1">{t('notifications.quietStart')}</label>
<input
type="time"
value={quietHoursStart}
onChange={(e) => setQuietHoursStart(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-xs text-bambu-gray mb-1">{t('notifications.quietEnd')}</label>
<input
type="time"
value={quietHoursEnd}
onChange={(e) => setQuietHoursEnd(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>
)}
</div>
{/* Daily Digest */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<label className="text-sm text-white">{t('notifications.dailyDigestLabel')}</label>
<p className="text-xs text-bambu-gray">{t('notifications.batchNotifications')}</p>
</div>
<Toggle
checked={dailyDigestEnabled}
onChange={setDailyDigestEnabled}
/>
</div>
{dailyDigestEnabled && (
<div>
<label className="block text-xs text-bambu-gray mb-1">{t('notifications.sendDigestAt')}</label>
<input
type="time"
value={dailyDigestTime}
onChange={(e) => setDailyDigestTime(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"
/>
<p className="text-xs text-bambu-gray mt-1">
{t('notifications.digestCollected')}
</p>
</div>
)}
</div>
{/* Event Toggles */}
<div className="space-y-3">
<p className="text-sm text-bambu-gray">{t('notifications.notificationEvents')}</p>
{/* Print Events */}
<div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.printEvents')}</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.start')}</span>
<Toggle checked={onPrintStart} onChange={setOnPrintStart} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.complete')}</span>
<Toggle checked={onPrintComplete} onChange={setOnPrintComplete} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.failed')}</span>
<Toggle checked={onPrintFailed} onChange={setOnPrintFailed} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.stopped')}</span>
<Toggle checked={onPrintStopped} onChange={setOnPrintStopped} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.progress')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.progressPercent')}</span>
</div>
<Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.billingChargeFailedLabel')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.billingChargeFailedDescription')}</span>
</div>
<Toggle checked={onBillingChargeFailed} onChange={setOnBillingChargeFailed} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.plateClearRequired')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.plateClearRequiredDescription')}</span>
</div>
<Toggle checked={onPlateClearRequired} onChange={setOnPlateClearRequired} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.printConfirmRequest')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.printConfirmRequestDescription')}</span>
</div>
<Toggle checked={onPrintConfirmRequest} onChange={setOnPrintConfirmRequest} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.bedCooled')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.bedCooledAfterPrint')}</span>
</div>
<Toggle checked={onBedCooled} onChange={setOnBedCooled} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.firstLayerCompleteLabel')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.firstLayerCompleteDescription')}</span>
</div>
<Toggle checked={onFirstLayerComplete} onChange={setOnFirstLayerComplete} />
</div>
</div>
</div>
{/* Printer Status Events */}
<div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.printerStatus')}</p>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.offline')}</span>
<Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.haSensorAlert')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.haSensorAlertDescription')}</span>
</div>
<Toggle checked={onHaSensorAlert} onChange={setOnHaSensorAlert} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.locationHaSensorAlert')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.locationHaSensorAlertDescription')}</span>
</div>
<Toggle checked={onLocationHaSensorAlert} onChange={setOnLocationHaSensorAlert} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.error')}</span>
<Toggle checked={onPrinterError} onChange={setOnPrinterError} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.aiFailureDetection')}</span>
<Toggle checked={onAiFailureDetection} onChange={setOnAiFailureDetection} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.lowFilament')}</span>
<Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.maintenance')}</span>
<Toggle checked={onMaintenanceDue} onChange={setOnMaintenanceDue} />
</div>
</div>
</div>
{/* Inventory Stock Alerts */}
<div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.inventoryAlerts')}</p>
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-white">{t('notifications.stockReorderAlert')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.stockReorderAlertDescription')}</span>
</div>
<Toggle checked={onStockReorderAlert} onChange={setOnStockReorderAlert} />
</div>
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-white">{t('notifications.stockBreakAlert')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.stockBreakAlertDescription')}</span>
</div>
<Toggle checked={onStockBreakAlert} onChange={setOnStockBreakAlert} />
</div>
</div>
</div>
{/* Messages other applications send (POST /notifications/app-message) */}
<div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-2">{t('notifications.connectedApps')}</p>
<div className="flex items-center justify-between">
<div>
<span className="text-sm text-white">{t('notifications.appMessages')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.appMessagesDescription')}</span>
</div>
<Toggle checked={onAppMessage} onChange={setOnAppMessage} />
</div>
</div>
{/* Per-event ntfy priority (#990) */}
{providerType === 'ntfy' && (() => {
const enabledEvents: Array<{ key: string; label: string }> = [];
if (onPrintStart) enabledEvents.push({ key: 'on_print_start', label: t('notifications.start') });
if (onPrintComplete) enabledEvents.push({ key: 'on_print_complete', label: t('notifications.complete') });
if (onPrintFailed) enabledEvents.push({ key: 'on_print_failed', label: t('notifications.failed') });
if (onPrintStopped) enabledEvents.push({ key: 'on_print_stopped', label: t('notifications.stopped') });
if (onPrintProgress) enabledEvents.push({ key: 'on_print_progress', label: t('notifications.progress') });
if (onBillingChargeFailed) enabledEvents.push({ key: 'on_billing_charge_failed', label: t('notifications.billingChargeFailedLabel') });
if (onPlateClearRequired) enabledEvents.push({ key: 'on_plate_clear_required', label: t('notifications.plateClearRequired') });
if (onPrintConfirmRequest) enabledEvents.push({ key: 'on_print_confirm_request', label: t('notifications.printConfirmRequest') });
if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
if (onHaSensorAlert) enabledEvents.push({ key: 'on_ha_sensor_alert', label: t('notifications.haSensorAlert') });
if (onLocationHaSensorAlert) enabledEvents.push({ key: 'on_location_ha_sensor_alert', label: t('notifications.locationHaSensorAlert') });
if (onAiFailureDetection) enabledEvents.push({ key: 'on_ai_failure_detection', label: t('notifications.aiFailureDetection') });
if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
if (onStockReorderAlert) enabledEvents.push({ key: 'on_stock_reorder_alert', label: t('notifications.stockReorderAlert') });
if (onStockBreakAlert) enabledEvents.push({ key: 'on_stock_break_alert', label: t('notifications.stockBreakAlert') });
if (enabledEvents.length === 0) return null;
return (
<div className="space-y-2 p-3 bg-bambu-dark rounded-lg">
<p className="text-xs text-bambu-gray uppercase tracking-wide mb-1">
{t('notifications.eventPriority.sectionTitle')}
</p>
<p className="text-xs text-bambu-gray mb-2">{t('notifications.eventPriority.helpNtfy')}</p>
<div className="space-y-2">
{enabledEvents.map((ev) => (
<div key={ev.key} className="flex items-center justify-between gap-3">
<span className="text-sm text-white">{ev.label}</span>
<select
value={eventPriorities[ev.key] ?? 3}
onChange={(e) => {
const next = Number(e.target.value);
setEventPriorities((prev) => ({ ...prev, [ev.key]: next }));
}}
className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-sm text-white focus:border-bambu-green focus:outline-none"
>
<option value={1}>{t('notifications.eventPriority.min')}</option>
<option value={2}>{t('notifications.eventPriority.low')}</option>
<option value={3}>{t('notifications.eventPriority.default')}</option>
<option value={4}>{t('notifications.eventPriority.high')}</option>
<option value={5}>{t('notifications.eventPriority.urgent')}</option>
</select>
</div>
))}
</div>
</div>
);
})()}
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button
type="button"
variant="secondary"
onClick={onClose}
className="flex-1"
>
{t('notifications.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 ? t('notifications.save') : t('notifications.add')}
</Button>
</div>
</form>
</div>
</div>
);
}