89c44b846f
Phase 5.1 — MATERIAL_PALETTE removal:
- Remove MATERIAL_PALETTE + _material_to_color() from step_processor.py
- build_part_colors() now returns {part→material_name} for Blender resolver
Phase 6 — Notification Center Refactor:
- Migration 051: add channel (activity|notification|alert) to audit_log,
add frequency (immediate|daily|never) to notification_configs
- Three notification channels: activity (per-render), notification (batch
order summaries), alert (admin infrastructure)
- Per-render emit_notification_sync calls demoted to channel=activity
- New emit_batch_render_notification_sync(): single summary notification
when all order lines reach terminal state ("47/50 succeeded, 3 failed")
- Beat task batch_render_notifications every 60s: safety-net for missed
batch notifications after order completion
- GET /notifications: defaults to channel IN (notification, alert);
accepts ?channel=activity for activity feed
- Unread count badge counts only notification+alert channels
- Notifications.tsx: three tabs (Notifications | Activity | Alerts)
- NotificationSettings.tsx: frequency dropdown per event type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
7.5 KiB
TypeScript
205 lines
7.5 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { Bell, RotateCcw } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
getNotificationConfigs,
|
|
updateNotificationConfig,
|
|
resetNotificationConfigs,
|
|
type NotificationConfig,
|
|
type NotificationFrequency,
|
|
} from '../api/notifications'
|
|
|
|
const EVENT_LABELS: Record<string, string> = {
|
|
'order.submitted': 'Order submitted',
|
|
'order.completed': 'Order completed',
|
|
'render.completed': 'Render completed (activity)',
|
|
'render.failed': 'Render failed (activity)',
|
|
'excel.imported': 'Excel imported',
|
|
}
|
|
|
|
const ALL_EVENTS = Object.keys(EVENT_LABELS)
|
|
const CHANNELS: Array<{ key: 'in_app' | 'email'; label: string; comingSoon?: boolean }> = [
|
|
{ key: 'in_app', label: 'In-App' },
|
|
{ key: 'email', label: 'E-Mail', comingSoon: true },
|
|
]
|
|
|
|
const FREQUENCY_OPTIONS: Array<{ value: string; label: string }> = [
|
|
{ value: 'immediate', label: 'Immediate' },
|
|
{ value: 'daily', label: 'Daily summary' },
|
|
{ value: 'never', label: 'Disabled' },
|
|
]
|
|
|
|
function Toggle({
|
|
enabled,
|
|
disabled,
|
|
onChange,
|
|
title,
|
|
}: {
|
|
enabled: boolean
|
|
disabled?: boolean
|
|
onChange: (v: boolean) => void
|
|
title?: string
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
title={title}
|
|
disabled={disabled}
|
|
onClick={() => !disabled && onChange(!enabled)}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
|
|
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'
|
|
} ${enabled ? 'bg-blue-600' : 'bg-gray-200'}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
|
enabled ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export default function NotificationSettingsPage() {
|
|
const qc = useQueryClient()
|
|
|
|
const { data: configs = [], isLoading } = useQuery({
|
|
queryKey: ['notification-configs'],
|
|
queryFn: getNotificationConfigs,
|
|
})
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ eventType, channel, enabled, frequency }: { eventType: string; channel: string; enabled: boolean; frequency?: NotificationFrequency }) =>
|
|
updateNotificationConfig(eventType, channel, enabled, frequency),
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['notification-configs'] }),
|
|
onError: () => toast.error('Failed to update setting'),
|
|
})
|
|
|
|
const resetMutation = useMutation({
|
|
mutationFn: resetNotificationConfigs,
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['notification-configs'] })
|
|
toast.success('Settings reset to defaults')
|
|
},
|
|
onError: () => toast.error('Failed to reset settings'),
|
|
})
|
|
|
|
// Build lookup maps
|
|
const configMap = new Map<string, boolean>()
|
|
const frequencyMap = new Map<string, NotificationFrequency>()
|
|
for (const c of configs) {
|
|
configMap.set(`${c.event_type}:${c.channel}`, c.enabled)
|
|
frequencyMap.set(`${c.event_type}:${c.channel}`, c.frequency ?? 'immediate')
|
|
}
|
|
|
|
const isEnabled = (event: string, channel: string) =>
|
|
configMap.get(`${event}:${channel}`) ?? (channel === 'in_app')
|
|
|
|
const getFrequency = (event: string, channel: string): NotificationFrequency =>
|
|
frequencyMap.get(`${event}:${channel}`) ?? 'immediate'
|
|
|
|
return (
|
|
<div className="p-6 space-y-5 max-w-2xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-content flex items-center gap-2">
|
|
<Bell size={20} />
|
|
Notification Settings
|
|
</h1>
|
|
<p className="text-sm text-content-muted mt-0.5">
|
|
Configure which events trigger notifications for you
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => resetMutation.mutate()}
|
|
disabled={resetMutation.isPending}
|
|
className="flex items-center gap-2 text-sm px-3 py-2 border border-border-default rounded-lg hover:bg-surface-hover transition-colors text-content-secondary"
|
|
>
|
|
<RotateCcw size={14} />
|
|
Reset to defaults
|
|
</button>
|
|
</div>
|
|
|
|
{/* Matrix table */}
|
|
<div className="bg-surface border border-border-default rounded-lg overflow-hidden">
|
|
<table className="w-full">
|
|
<thead className="bg-surface-alt border-b border-border-default">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-semibold text-content-muted uppercase tracking-wide">
|
|
Event
|
|
</th>
|
|
{CHANNELS.map(ch => (
|
|
<th
|
|
key={ch.key}
|
|
className="px-4 py-3 text-center text-xs font-semibold text-content-muted uppercase tracking-wide w-32"
|
|
>
|
|
{ch.label}
|
|
{ch.comingSoon && (
|
|
<span className="ml-1 text-xs font-normal normal-case text-content-muted">(soon)</span>
|
|
)}
|
|
</th>
|
|
))}
|
|
<th className="px-4 py-3 text-center text-xs font-semibold text-content-muted uppercase tracking-wide w-40">
|
|
Frequency
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border-default">
|
|
{isLoading ? (
|
|
<tr>
|
|
<td colSpan={CHANNELS.length + 2} className="px-4 py-8 text-center text-sm text-content-muted">
|
|
Loading...
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
ALL_EVENTS.map(event => (
|
|
<tr key={event} className="hover:bg-surface-hover transition-colors">
|
|
<td className="px-4 py-3 text-sm text-content">
|
|
{EVENT_LABELS[event] || event}
|
|
</td>
|
|
{CHANNELS.map(ch => (
|
|
<td key={ch.key} className="px-4 py-3 text-center">
|
|
<Toggle
|
|
enabled={isEnabled(event, ch.key)}
|
|
disabled={ch.comingSoon || updateMutation.isPending}
|
|
onChange={enabled =>
|
|
updateMutation.mutate({ eventType: event, channel: ch.key, enabled })
|
|
}
|
|
title={ch.comingSoon ? 'Email notifications — coming soon' : undefined}
|
|
/>
|
|
</td>
|
|
))}
|
|
<td className="px-4 py-3 text-center">
|
|
<select
|
|
value={getFrequency(event, 'in_app')}
|
|
disabled={updateMutation.isPending || !isEnabled(event, 'in_app')}
|
|
onChange={e =>
|
|
updateMutation.mutate({
|
|
eventType: event,
|
|
channel: 'in_app',
|
|
enabled: isEnabled(event, 'in_app'),
|
|
frequency: e.target.value as NotificationFrequency,
|
|
})
|
|
}
|
|
className="text-xs border border-border-default rounded px-2 py-1 bg-surface text-content disabled:opacity-40"
|
|
>
|
|
{FREQUENCY_OPTIONS.map(opt => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<p className="text-xs text-content-muted">
|
|
In-App notifications appear in the bell icon in the sidebar.
|
|
Email notifications are currently deactivated.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|