feat(F-G-H-I): STL cache, invoices, import validation, notification settings
Phase F — STL Hash Cache:
- Migration 041: step_file_hash column on cad_files
- cache_service.py: SHA256 hash + MinIO-backed STL cache (check/store)
- render_step_thumbnail: compute+persist hash before render
- generate_stl_cache: check MinIO cache before cadquery conversion, store after
Phase G — Invoices:
- Migration 042: invoices + invoice_lines tables with RLS
- Invoice/InvoiceLine models + schemas
- billing service: generate_invoice_number (INV-YYYY-NNNN), create/list/get/delete/PDF
- WeasyPrint PDF generation; backend Dockerfile + pyproject.toml deps
- invoice_router with 6 endpoints; registered in main.py
- frontend: Billing.tsx page + api/billing.ts; route + nav link
Phase H — Import Sanity Check:
- Migration 043: import_validations table
- ImportValidation model + schemas
- run_sanity_check: material fuzzy-match (cutoff=0.8), STEP availability, duplicate detection
- validate_excel_import Celery task (queue: step_processing)
- uploads.py: create ImportValidation on /excel, fire task, expose GET /validations/{id}
- frontend: Upload.tsx polling ValidationDialog with Ampel status indicators
Phase I — Notification Settings:
- Migration 044: notification_configs table (user×event×channel toggles)
- NotificationConfig model + seeds (in_app=true, email=false)
- get/upsert/reset config endpoints on /notifications/config
- frontend: NotificationSettings.tsx page + api/notifications.ts extensions
Infrastructure:
- docker-compose.yml: add worker-thumbnail service (concurrency=1, Q=thumbnail_rendering)
- Fix Dockerfile: libgdk-pixbuf-2.0-0 (correct Debian bookworm package name)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Bell, RotateCcw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
getNotificationConfigs,
|
||||
updateNotificationConfig,
|
||||
resetNotificationConfigs,
|
||||
type NotificationConfig,
|
||||
} from '../api/notifications'
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
'order.submitted': 'Order submitted',
|
||||
'order.completed': 'Order completed',
|
||||
'render.completed': 'Render completed',
|
||||
'render.failed': 'Render failed',
|
||||
'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 },
|
||||
]
|
||||
|
||||
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 }: { eventType: string; channel: string; enabled: boolean }) =>
|
||||
updateNotificationConfig(eventType, channel, enabled),
|
||||
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 map: eventType+channel → enabled
|
||||
const configMap = new Map<string, boolean>()
|
||||
for (const c of configs) {
|
||||
configMap.set(`${c.event_type}:${c.channel}`, c.enabled)
|
||||
}
|
||||
|
||||
const isEnabled = (event: string, channel: string) =>
|
||||
configMap.get(`${event}:${channel}`) ?? (channel === 'in_app')
|
||||
|
||||
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>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-default">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={CHANNELS.length + 1} 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>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user