Files
breakpilot-lehrer/studio-v2/lib/AlertsB2BContext.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

1166 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
// ============================================
// TYPES
// ============================================
export type ImportanceLabel = 'KRITISCH' | 'DRINGEND' | 'WICHTIG' | 'PRUEFEN' | 'INFO'
export type DecisionLabel = 'relevant' | 'irrelevant' | 'needs_review'
export type SourceType = 'email' | 'rss'
export type NoiseMode = 'STRICT' | 'BALANCED' | 'BROAD'
export type Package = 'PARKING' | 'EV_CHARGING' | 'FUEL' | 'TANK_MONITORING'
export interface AlertSource {
id: string
tenantId: string
type: SourceType
inboundAddress?: string
rssUrl?: string
label: string
active: boolean
createdAt: Date
}
export interface B2BHit {
id: string
tenantId: string
topicId: string
sourceType: SourceType
sourceRef: string
originalUrl: string
canonicalUrl: string
title: string
snippet: string
fullText?: string
foundAt: Date
language?: string
countryGuess?: string
buyerGuess?: string
deadlineGuess?: string
importanceScore: number
importanceLabel: ImportanceLabel
decisionLabel: DecisionLabel
decisionConfidence: number
decisionTrace?: DecisionTrace
isRead: boolean
userFeedback?: 'relevant' | 'irrelevant'
createdAt: Date
}
export interface DecisionTrace {
rulesTriggered: string[]
llmUsed: boolean
llmConfidence?: number
signals: {
procurementSignalsFound: string[]
publicBuyerSignalsFound: string[]
productSignalsFound: string[]
negativesFound: string[]
}
}
export interface B2BTopic {
id: string
tenantId: string
name: string
package: Package
intent: TopicIntent
filters: TopicFilters
decisionPolicy: DecisionPolicy
importanceModelId: string
status: 'active' | 'paused'
createdAt: Date
}
export interface TopicIntent {
goalStatement: string
mustHaveAnyN: number
mustHave: { type: 'keyword'; value: string }[]
publicBuyerSignalsAny: string[]
procurementSignalsAny: string[]
}
export interface TopicFilters {
hardExcludesAny: string[]
softExcludesAny: string[]
}
export interface DecisionPolicy {
rulesFirst: boolean
llmMode: 'ALWAYS' | 'GRAYZONE_ONLY' | 'NEVER'
autoRelevantMinConf: number
needsReviewRange: [number, number]
autoIrrelevantMaxConf: number
}
export interface ImportanceModel {
modelId: string
scoreRange: [number, number]
weights: {
deadlineProximity: number
procurementIntentStrength: number
publicBuyerStrength: number
productMatchStrength: number
sourceTrust: number
novelty: number
}
thresholds: {
infoMax: number
reviewMin: number
importantMin: number
urgentMin: number
criticalMin: number
}
topNCaps: {
dailyDigestMax: number
weeklyDigestMax: number
perTopicDailyMax: number
}
}
export interface B2BTemplate {
templateId: string
templateName: string
templateDescription: string
targetRoles: string[]
industry?: string
companyExample?: string
topics: Omit<B2BTopic, 'id' | 'tenantId' | 'createdAt'>[]
importanceModel: ImportanceModel
guidedConfig: GuidedConfig
notificationPreset: NotificationPreset
}
export interface GuidedConfig {
regionSelector: {
type: 'multi_select'
default: string[]
options: string[]
}
languageSelector: {
type: 'multi_select'
default: string[]
options: string[]
}
packageSelector: {
type: 'multi_select'
default: Package[]
options: Package[]
}
noiseMode: {
type: 'single_select'
default: NoiseMode
options: NoiseMode[]
}
}
export interface NotificationPreset {
presetId: string
channels: string[]
cadence: 'REALTIME' | 'HOURLY' | 'DAILY' | 'WEEKLY'
timeLocal: string
includeStatuses: DecisionLabel[]
maxItems: number
}
export interface B2BTenant {
id: string
name: string
companyName: string
industry: string
plan: 'trial' | 'starter' | 'professional' | 'enterprise'
inboundEmailDomain: string
createdAt: Date
settings: B2BSettings
}
export interface B2BSettings {
migrationCompleted: boolean
wizardCompleted: boolean
selectedTemplateId?: string
selectedRegions: string[]
selectedLanguages: string[]
selectedPackages: Package[]
noiseMode: NoiseMode
notificationCadence: 'REALTIME' | 'HOURLY' | 'DAILY' | 'WEEKLY'
notificationTime: string
maxDigestItems: number
}
// ============================================
// HECTRONIC TEMPLATE (Real Example)
// ============================================
export const hectronicTemplate: B2BTemplate = {
templateId: 'tpl_hectronic_public_procurement_v1',
templateName: 'Hectronic Kommunale Ausschreibungen (Parking • EV Charging • Fuel)',
templateDescription: 'Findet weltweit kommunale Ausschreibungen für Parkscheinautomaten/Parkraummanagement, Payment an Ladepunkten und Fuel-Terminals. Reduziert News/Jobs/Zubehör-Rauschen stark.',
targetRoles: ['sales_ops', 'bid_management', 'product_marketing', 'regional_sales'],
industry: 'Payment & Infrastructure',
companyExample: 'Hectronic GmbH',
guidedConfig: {
regionSelector: {
type: 'multi_select',
default: ['EUROPE'],
options: ['EUROPE', 'DACH', 'NORTH_AMERICA', 'MIDDLE_EAST', 'APAC', 'GLOBAL']
},
languageSelector: {
type: 'multi_select',
default: ['de', 'en'],
options: ['de', 'en', 'fr', 'es', 'it', 'nl', 'pl', 'sv', 'da', 'no', 'fi', 'pt']
},
packageSelector: {
type: 'multi_select',
default: ['PARKING', 'EV_CHARGING'],
options: ['PARKING', 'EV_CHARGING', 'FUEL', 'TANK_MONITORING']
},
noiseMode: {
type: 'single_select',
default: 'STRICT',
options: ['STRICT', 'BALANCED', 'BROAD']
}
},
topics: [
{
name: 'Parking Parkscheinautomaten & Parkraummanagement (Kommunen)',
package: 'PARKING',
intent: {
goalStatement: 'Finde kommunale Ausschreibungen/Procurements für Parkscheinautomaten, Pay Stations, Parkraummanagement-Software und zugehörige Services.',
mustHaveAnyN: 2,
mustHave: [
{ type: 'keyword', value: 'parking meter' },
{ type: 'keyword', value: 'pay and display' },
{ type: 'keyword', value: 'parking pay station' },
{ type: 'keyword', value: 'Parkscheinautomat' },
{ type: 'keyword', value: 'Parkautomat' },
{ type: 'keyword', value: 'Parkraumbewirtschaftung' },
{ type: 'keyword', value: 'backoffice' },
{ type: 'keyword', value: 'management software' }
],
publicBuyerSignalsAny: [
'municipality', 'city', 'city council', 'local authority', 'public tender',
'Kommune', 'Stadt', 'Gemeinde', 'Landkreis', 'öffentliche Ausschreibung', 'Vergabe'
],
procurementSignalsAny: [
'tender', 'procurement', 'RFP', 'RFQ', 'invitation to tender', 'contract notice',
'Ausschreibung', 'Vergabe', 'EU-weite Ausschreibung', 'Bekanntmachung'
]
},
filters: {
hardExcludesAny: [
'keyboard', 'mouse', 'headset', 'toner', 'office supplies',
'Tastatur', 'Maus', 'Headset', 'Büromaterial',
'job', 'hiring', 'stellenanzeige', 'bewerbung',
'parking fine', 'parking ticket', 'Strafzettel', 'Knöllchen'
],
softExcludesAny: [
'press release', 'marketing', 'blog', 'opinion',
'pressemitteilung', 'werbung', 'meinung',
'maintenance only', 'consulting only', 'support only'
]
},
decisionPolicy: {
rulesFirst: true,
llmMode: 'GRAYZONE_ONLY',
autoRelevantMinConf: 0.80,
needsReviewRange: [0.50, 0.79],
autoIrrelevantMaxConf: 0.49
},
importanceModelId: 'imp_hectronic_v1',
status: 'active'
},
{
name: 'EV Charging Payment/Terminals in kommunaler Ladeinfrastruktur',
package: 'EV_CHARGING',
intent: {
goalStatement: 'Finde kommunale Ausschreibungen für Ladeinfrastruktur mit Payment-Terminals, Ad-hoc-Kartenzahlung, Backend/Management.',
mustHaveAnyN: 2,
mustHave: [
{ type: 'keyword', value: 'EV charging' },
{ type: 'keyword', value: 'charging station' },
{ type: 'keyword', value: 'charge point' },
{ type: 'keyword', value: 'Ladesäule' },
{ type: 'keyword', value: 'Ladeinfrastruktur' },
{ type: 'keyword', value: 'payment terminal' },
{ type: 'keyword', value: 'card payment' },
{ type: 'keyword', value: 'contactless' },
{ type: 'keyword', value: 'Bezahlsystem' },
{ type: 'keyword', value: 'Kartenzahlung' }
],
publicBuyerSignalsAny: [
'municipality', 'city', 'public works', 'local authority',
'Kommune', 'Stadt', 'Gemeinde', 'Kommunal'
],
procurementSignalsAny: [
'tender', 'procurement', 'RFP', 'RFQ', 'contract notice',
'Ausschreibung', 'Vergabe', 'Bekanntmachung'
]
},
filters: {
hardExcludesAny: [
'stock', 'investor', 'funding round', 'press release',
'Aktie', 'Investor', 'Finanzierung', 'Pressemitteilung',
'job', 'hiring', 'stellenanzeige',
'private home charger', 'wallbox'
],
softExcludesAny: [
'funding program', 'grant', 'Förderprogramm', 'Zuschuss',
'pilot project', 'research project', 'Modellprojekt'
]
},
decisionPolicy: {
rulesFirst: true,
llmMode: 'GRAYZONE_ONLY',
autoRelevantMinConf: 0.80,
needsReviewRange: [0.50, 0.79],
autoIrrelevantMaxConf: 0.49
},
importanceModelId: 'imp_hectronic_v1',
status: 'active'
},
{
name: 'Fuel Fleet/Public Fuel Terminals',
package: 'FUEL',
intent: {
goalStatement: 'Finde Ausschreibungen für (un)bemannte Tankautomaten/Fuel-Terminals, Fuel-Management, Flottenbetankung.',
mustHaveAnyN: 2,
mustHave: [
{ type: 'keyword', value: 'fuel terminal' },
{ type: 'keyword', value: 'fuel management' },
{ type: 'keyword', value: 'unmanned fuel station' },
{ type: 'keyword', value: 'fleet fueling' },
{ type: 'keyword', value: 'Tankautomat' },
{ type: 'keyword', value: 'Tankterminal' },
{ type: 'keyword', value: 'Flottenbetankung' },
{ type: 'keyword', value: 'Betriebstankstelle' }
],
publicBuyerSignalsAny: ['municipality', 'utilities', 'public works', 'Stadtwerke', 'Kommunal'],
procurementSignalsAny: ['tender', 'procurement', 'RFP', 'Ausschreibung', 'Vergabe']
},
filters: {
hardExcludesAny: ['fuel price', 'oil price', 'Spritpreise', 'Ölpreis', 'job', 'hiring', 'stellenanzeige'],
softExcludesAny: ['market news', 'commodity', 'Börse', 'Pressemitteilung']
},
decisionPolicy: {
rulesFirst: true,
llmMode: 'GRAYZONE_ONLY',
autoRelevantMinConf: 0.80,
needsReviewRange: [0.50, 0.79],
autoIrrelevantMaxConf: 0.49
},
importanceModelId: 'imp_hectronic_v1',
status: 'active'
}
],
importanceModel: {
modelId: 'imp_hectronic_v1',
scoreRange: [0, 100],
weights: {
deadlineProximity: 28,
procurementIntentStrength: 22,
publicBuyerStrength: 18,
productMatchStrength: 18,
sourceTrust: 8,
novelty: 6
},
thresholds: {
infoMax: 29,
reviewMin: 30,
importantMin: 50,
urgentMin: 70,
criticalMin: 85
},
topNCaps: {
dailyDigestMax: 10,
weeklyDigestMax: 20,
perTopicDailyMax: 6
}
},
notificationPreset: {
presetId: 'notif_hectronic_digest_v1',
channels: ['email'],
cadence: 'DAILY',
timeLocal: '08:00',
includeStatuses: ['relevant', 'needs_review'],
maxItems: 10
}
}
// ============================================
// MOCK DATA (Demo Hits for Hectronic)
// ============================================
const mockHectronicHits: B2BHit[] = [
{
id: 'hit-hec-001',
tenantId: 'hectronic-demo',
topicId: 'hec_parking_v1',
sourceType: 'email',
sourceRef: 'ga-msg-001',
originalUrl: 'https://www.evergabe.de/auftraege/parkscheinautomaten-stadt-muenchen',
canonicalUrl: 'https://evergabe.de/auftraege/parkscheinautomaten-muenchen',
title: 'Stadt München: Lieferung und Installation von 150 Parkscheinautomaten',
snippet: 'Die Landeshauptstadt München schreibt die Beschaffung von 150 modernen Parkscheinautomaten inkl. Backoffice-Software für das Stadtgebiet aus. Frist: 15.03.2026.',
foundAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
language: 'de',
countryGuess: 'Germany',
buyerGuess: 'Landeshauptstadt München',
deadlineGuess: '2026-03-15',
importanceScore: 92,
importanceLabel: 'KRITISCH',
decisionLabel: 'relevant',
decisionConfidence: 0.95,
decisionTrace: {
rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'],
llmUsed: false,
signals: {
procurementSignalsFound: ['Ausschreibung', 'Beschaffung', 'Vergabe'],
publicBuyerSignalsFound: ['Landeshauptstadt', 'Stadt München'],
productSignalsFound: ['Parkscheinautomaten', 'Backoffice-Software'],
negativesFound: []
}
},
isRead: false,
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000)
},
{
id: 'hit-hec-002',
tenantId: 'hectronic-demo',
topicId: 'hec_ev_charging_v1',
sourceType: 'rss',
sourceRef: 'rss-item-002',
originalUrl: 'https://ted.europa.eu/notice/2026-12345',
canonicalUrl: 'https://ted.europa.eu/notice/2026-12345',
title: 'EU Tender: Supply of EV Charging Infrastructure with Payment Terminals - City of Amsterdam',
snippet: 'Contract notice for the supply, installation and maintenance of 200 public EV charging points with integrated contactless payment terminals. Deadline: 28.02.2026.',
foundAt: new Date(Date.now() - 5 * 60 * 60 * 1000),
language: 'en',
countryGuess: 'Netherlands',
buyerGuess: 'City of Amsterdam',
deadlineGuess: '2026-02-28',
importanceScore: 88,
importanceLabel: 'KRITISCH',
decisionLabel: 'relevant',
decisionConfidence: 0.92,
decisionTrace: {
rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'],
llmUsed: false,
signals: {
procurementSignalsFound: ['Contract notice', 'tender', 'supply'],
publicBuyerSignalsFound: ['City of Amsterdam', 'public'],
productSignalsFound: ['EV charging', 'charging points', 'payment terminals', 'contactless'],
negativesFound: []
}
},
isRead: false,
createdAt: new Date(Date.now() - 5 * 60 * 60 * 1000)
},
{
id: 'hit-hec-003',
tenantId: 'hectronic-demo',
topicId: 'hec_parking_v1',
sourceType: 'email',
sourceRef: 'ga-msg-003',
originalUrl: 'https://vergabe.niedersachsen.de/NetServer/notice/8765432',
canonicalUrl: 'https://vergabe.niedersachsen.de/notice/8765432',
title: 'Gemeinde Seevetal: Erneuerung Parkraumbewirtschaftungssystem',
snippet: 'Öffentliche Ausschreibung zur Erneuerung des Parkraumbewirtschaftungssystems mit 25 Pay-Stations und zentraler Management-Software.',
foundAt: new Date(Date.now() - 8 * 60 * 60 * 1000),
language: 'de',
countryGuess: 'Germany',
buyerGuess: 'Gemeinde Seevetal',
deadlineGuess: '2026-04-01',
importanceScore: 78,
importanceLabel: 'DRINGEND',
decisionLabel: 'relevant',
decisionConfidence: 0.88,
decisionTrace: {
rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'],
llmUsed: false,
signals: {
procurementSignalsFound: ['Öffentliche Ausschreibung', 'Erneuerung'],
publicBuyerSignalsFound: ['Gemeinde'],
productSignalsFound: ['Parkraumbewirtschaftungssystem', 'Pay-Stations', 'Management-Software'],
negativesFound: []
}
},
isRead: false,
createdAt: new Date(Date.now() - 8 * 60 * 60 * 1000)
},
{
id: 'hit-hec-004',
tenantId: 'hectronic-demo',
topicId: 'hec_parking_v1',
sourceType: 'email',
sourceRef: 'ga-msg-004',
originalUrl: 'https://news.example.com/parking-industry-trends-2026',
canonicalUrl: 'https://news.example.com/parking-trends-2026',
title: 'Parking Industry Trends 2026: Smart Cities investieren in digitale Lösungen',
snippet: 'Branchenanalyse zeigt wachsende Nachfrage nach vernetzten Parkscheinautomaten. Experten erwarten 15% Marktwachstum.',
foundAt: new Date(Date.now() - 12 * 60 * 60 * 1000),
language: 'de',
countryGuess: 'Germany',
buyerGuess: undefined,
deadlineGuess: undefined,
importanceScore: 15,
importanceLabel: 'INFO',
decisionLabel: 'irrelevant',
decisionConfidence: 0.91,
decisionTrace: {
rulesTriggered: ['soft_exclude_triggered'],
llmUsed: false,
signals: {
procurementSignalsFound: [],
publicBuyerSignalsFound: [],
productSignalsFound: ['Parkscheinautomaten'],
negativesFound: ['Branchenanalyse', 'Experten', 'Marktwachstum']
}
},
isRead: true,
createdAt: new Date(Date.now() - 12 * 60 * 60 * 1000)
},
{
id: 'hit-hec-005',
tenantId: 'hectronic-demo',
topicId: 'hec_ev_charging_v1',
sourceType: 'rss',
sourceRef: 'rss-item-005',
originalUrl: 'https://ausschreibungen.at/ladeinfrastruktur-wien',
canonicalUrl: 'https://ausschreibungen.at/ladeinfrastruktur-wien',
title: 'Stadt Wien: Rahmenvertrag Ladeinfrastruktur öffentlicher Raum',
snippet: 'Vergabeverfahren für Rahmenvertrag über Lieferung und Betrieb von Ladeinfrastruktur im öffentlichen Raum. Details hinter Login.',
foundAt: new Date(Date.now() - 18 * 60 * 60 * 1000),
language: 'de',
countryGuess: 'Austria',
buyerGuess: 'Stadt Wien',
deadlineGuess: undefined,
importanceScore: 55,
importanceLabel: 'WICHTIG',
decisionLabel: 'needs_review',
decisionConfidence: 0.62,
decisionTrace: {
rulesTriggered: ['procurement_signal_match', 'public_buyer_match'],
llmUsed: true,
llmConfidence: 0.62,
signals: {
procurementSignalsFound: ['Vergabeverfahren', 'Rahmenvertrag'],
publicBuyerSignalsFound: ['Stadt Wien', 'öffentlicher Raum'],
productSignalsFound: ['Ladeinfrastruktur'],
negativesFound: ['Details hinter Login']
}
},
isRead: false,
createdAt: new Date(Date.now() - 18 * 60 * 60 * 1000)
},
{
id: 'hit-hec-006',
tenantId: 'hectronic-demo',
topicId: 'hec_fuel_v1',
sourceType: 'email',
sourceRef: 'ga-msg-006',
originalUrl: 'https://vergabe.bund.de/tankautomat-bundeswehr',
canonicalUrl: 'https://vergabe.bund.de/tankautomat-bw',
title: 'Bundesamt für Ausrüstung: Tankautomaten für Liegenschaften',
snippet: 'Beschaffung von unbemannten Tankautomaten für Flottenbetankung an 12 Bundeswehr-Standorten. EU-weite Ausschreibung.',
foundAt: new Date(Date.now() - 24 * 60 * 60 * 1000),
language: 'de',
countryGuess: 'Germany',
buyerGuess: 'Bundesamt für Ausrüstung',
deadlineGuess: '2026-05-15',
importanceScore: 72,
importanceLabel: 'DRINGEND',
decisionLabel: 'relevant',
decisionConfidence: 0.86,
decisionTrace: {
rulesTriggered: ['procurement_signal_match', 'public_buyer_match', 'product_match'],
llmUsed: false,
signals: {
procurementSignalsFound: ['Beschaffung', 'EU-weite Ausschreibung'],
publicBuyerSignalsFound: ['Bundesamt'],
productSignalsFound: ['Tankautomaten', 'Flottenbetankung', 'unbemannt'],
negativesFound: []
}
},
isRead: true,
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000)
}
]
// ============================================
// DEFAULT SETTINGS
// ============================================
const defaultB2BSettings: B2BSettings = {
migrationCompleted: false,
wizardCompleted: false,
selectedRegions: ['EUROPE'],
selectedLanguages: ['de', 'en'],
selectedPackages: ['PARKING', 'EV_CHARGING'],
noiseMode: 'STRICT',
notificationCadence: 'DAILY',
notificationTime: '08:00',
maxDigestItems: 10
}
const defaultTenant: B2BTenant = {
id: 'demo-tenant',
name: 'Demo Account',
companyName: 'Meine Firma GmbH',
industry: 'Payment & Infrastructure',
plan: 'trial',
inboundEmailDomain: 'alerts.breakpilot.de',
createdAt: new Date(),
settings: defaultB2BSettings
}
// ============================================
// CONTEXT
// ============================================
interface AlertsB2BContextType {
// Tenant
tenant: B2BTenant
updateTenant: (updates: Partial<B2BTenant>) => void
// Settings
settings: B2BSettings
updateSettings: (updates: Partial<B2BSettings>) => void
// Templates
availableTemplates: B2BTemplate[]
selectedTemplate: B2BTemplate | null
selectTemplate: (templateId: string) => void
// Sources
sources: AlertSource[]
addSource: (source: Omit<AlertSource, 'id' | 'createdAt'>) => void
removeSource: (id: string) => void
generateInboundEmail: () => string
// Topics
topics: B2BTopic[]
addTopic: (topic: Omit<B2BTopic, 'id' | 'createdAt'>) => void
updateTopic: (id: string, updates: Partial<B2BTopic>) => void
removeTopic: (id: string) => void
// Hits
hits: B2BHit[]
unreadCount: number
relevantCount: number
needsReviewCount: number
markAsRead: (id: string) => void
markAllAsRead: () => void
submitFeedback: (id: string, feedback: 'relevant' | 'irrelevant') => void
processEmailContent: (emailContent: string, emailSubject?: string) => B2BHit
// Digest
getDigest: (maxItems?: number) => B2BHit[]
// Loading/Error
isLoading: boolean
error: string | null
}
const AlertsB2BContext = createContext<AlertsB2BContextType | null>(null)
// LocalStorage Keys
const B2B_TENANT_KEY = 'bp_b2b_tenant'
const B2B_SETTINGS_KEY = 'bp_b2b_settings'
const B2B_SOURCES_KEY = 'bp_b2b_sources'
const B2B_TOPICS_KEY = 'bp_b2b_topics'
const B2B_HITS_KEY = 'bp_b2b_hits'
export function AlertsB2BProvider({ children }: { children: ReactNode }) {
const [tenant, setTenant] = useState<B2BTenant>(defaultTenant)
const [settings, setSettings] = useState<B2BSettings>(defaultB2BSettings)
const [sources, setSources] = useState<AlertSource[]>([])
const [topics, setTopics] = useState<B2BTopic[]>([])
const [hits, setHits] = useState<B2BHit[]>([])
const [selectedTemplate, setSelectedTemplate] = useState<B2BTemplate | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [mounted, setMounted] = useState(false)
// Available templates
const availableTemplates: B2BTemplate[] = [hectronicTemplate]
// Load from localStorage
useEffect(() => {
const storedTenant = localStorage.getItem(B2B_TENANT_KEY)
const storedSettings = localStorage.getItem(B2B_SETTINGS_KEY)
const storedSources = localStorage.getItem(B2B_SOURCES_KEY)
const storedTopics = localStorage.getItem(B2B_TOPICS_KEY)
const storedHits = localStorage.getItem(B2B_HITS_KEY)
if (storedTenant) {
try {
const parsed = JSON.parse(storedTenant)
setTenant({ ...defaultTenant, ...parsed, createdAt: new Date(parsed.createdAt) })
} catch (e) {
console.error('Error parsing tenant:', e)
}
}
if (storedSettings) {
try {
setSettings({ ...defaultB2BSettings, ...JSON.parse(storedSettings) })
} catch (e) {
console.error('Error parsing settings:', e)
}
}
if (storedSources) {
try {
const parsed = JSON.parse(storedSources)
setSources(parsed.map((s: any) => ({ ...s, createdAt: new Date(s.createdAt) })))
} catch (e) {
console.error('Error parsing sources:', e)
}
}
if (storedTopics) {
try {
const parsed = JSON.parse(storedTopics)
setTopics(parsed.map((t: any) => ({ ...t, createdAt: new Date(t.createdAt) })))
} catch (e) {
console.error('Error parsing topics:', e)
}
}
if (storedHits) {
try {
const parsed = JSON.parse(storedHits)
setHits(parsed.map((h: any) => ({
...h,
foundAt: new Date(h.foundAt),
createdAt: new Date(h.createdAt)
})))
} catch (e) {
console.error('Error parsing hits:', e)
// Load mock data
setHits(mockHectronicHits)
}
} else {
// Load mock data for demo
setHits(mockHectronicHits)
}
setMounted(true)
}, [])
// Save to localStorage
useEffect(() => {
if (mounted) {
localStorage.setItem(B2B_TENANT_KEY, JSON.stringify(tenant))
}
}, [tenant, mounted])
useEffect(() => {
if (mounted) {
localStorage.setItem(B2B_SETTINGS_KEY, JSON.stringify(settings))
}
}, [settings, mounted])
useEffect(() => {
if (mounted) {
localStorage.setItem(B2B_SOURCES_KEY, JSON.stringify(sources))
}
}, [sources, mounted])
useEffect(() => {
if (mounted) {
localStorage.setItem(B2B_TOPICS_KEY, JSON.stringify(topics))
}
}, [topics, mounted])
useEffect(() => {
if (mounted && hits.length > 0) {
localStorage.setItem(B2B_HITS_KEY, JSON.stringify(hits))
}
}, [hits, mounted])
// Tenant operations
const updateTenant = useCallback((updates: Partial<B2BTenant>) => {
setTenant(prev => ({ ...prev, ...updates }))
}, [])
// Settings operations
const updateSettings = useCallback((updates: Partial<B2BSettings>) => {
setSettings(prev => ({ ...prev, ...updates }))
}, [])
// Template operations
const selectTemplate = useCallback((templateId: string) => {
const template = availableTemplates.find(t => t.templateId === templateId)
if (template) {
setSelectedTemplate(template)
// Auto-apply template settings
updateSettings({
selectedTemplateId: templateId,
selectedRegions: template.guidedConfig.regionSelector.default,
selectedLanguages: template.guidedConfig.languageSelector.default,
selectedPackages: template.guidedConfig.packageSelector.default,
noiseMode: template.guidedConfig.noiseMode.default,
notificationCadence: template.notificationPreset.cadence,
notificationTime: template.notificationPreset.timeLocal,
maxDigestItems: template.notificationPreset.maxItems
})
// Create topics from template
const newTopics: B2BTopic[] = template.topics.map((t, idx) => ({
...t,
id: `topic-${Date.now()}-${idx}`,
tenantId: tenant.id,
createdAt: new Date()
}))
setTopics(newTopics)
}
}, [availableTemplates, tenant.id, updateSettings])
// Source operations
const generateInboundEmail = useCallback(() => {
const token = Math.random().toString(36).substring(2, 10)
return `alerts+${tenant.id}-${token}@${tenant.inboundEmailDomain}`
}, [tenant.id, tenant.inboundEmailDomain])
const addSource = useCallback((source: Omit<AlertSource, 'id' | 'createdAt'>) => {
const newSource: AlertSource = {
...source,
id: `source-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
createdAt: new Date()
}
setSources(prev => [...prev, newSource])
}, [])
const removeSource = useCallback((id: string) => {
setSources(prev => prev.filter(s => s.id !== id))
}, [])
// Topic operations
const addTopic = useCallback((topic: Omit<B2BTopic, 'id' | 'createdAt'>) => {
const newTopic: B2BTopic = {
...topic,
id: `topic-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
createdAt: new Date()
}
setTopics(prev => [...prev, newTopic])
}, [])
const updateTopic = useCallback((id: string, updates: Partial<B2BTopic>) => {
setTopics(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t))
}, [])
const removeTopic = useCallback((id: string) => {
setTopics(prev => prev.filter(t => t.id !== id))
}, [])
// Hit operations
const markAsRead = useCallback((id: string) => {
setHits(prev => prev.map(h => h.id === id ? { ...h, isRead: true } : h))
}, [])
const markAllAsRead = useCallback(() => {
setHits(prev => prev.map(h => ({ ...h, isRead: true })))
}, [])
const submitFeedback = useCallback((id: string, feedback: 'relevant' | 'irrelevant') => {
setHits(prev => prev.map(h => h.id === id ? { ...h, userFeedback: feedback } : h))
}, [])
// Process Email Content (Manual Import for Testing)
const processEmailContent = useCallback((emailContent: string, emailSubject?: string): B2BHit => {
// Parse email content to extract useful information
const lines = emailContent.split('\n').filter(l => l.trim())
const title = emailSubject || lines[0]?.slice(0, 100) || 'Manuell eingefuegter Alert'
// Try to extract URLs from content
const urlRegex = /(https?:\/\/[^\s<>"]+)/g
const urls = emailContent.match(urlRegex) || []
const firstUrl = urls[0] || 'https://example.com/manual-import'
// Extract snippet (first meaningful paragraph)
const snippet = lines.slice(0, 3).join(' ').slice(0, 300) || emailContent.slice(0, 300)
// Simulate AI analysis - look for procurement signals
const procurementSignals = ['ausschreibung', 'tender', 'vergabe', 'beschaffung', 'auftrag',
'angebot', 'submission', 'procurement', 'rfp', 'rfq', 'bid']
const productSignals = ['parking', 'parkschein', 'ladesäule', 'ev charging', 'ladestation',
'tankstelle', 'fuel', 'bezahlterminal', 'payment']
const buyerSignals = ['stadt', 'kommune', 'gemeinde', 'city', 'municipality', 'council',
'stadtwerke', 'öffentlich', 'public']
const negativeSignals = ['stellenangebot', 'job', 'karriere', 'news', 'blog', 'press release']
const lowerContent = emailContent.toLowerCase()
const foundProcurement = procurementSignals.filter(s => lowerContent.includes(s))
const foundProducts = productSignals.filter(s => lowerContent.includes(s))
const foundBuyers = buyerSignals.filter(s => lowerContent.includes(s))
const foundNegatives = negativeSignals.filter(s => lowerContent.includes(s))
// Calculate importance score (0-100)
let score = 30 // base score
score += foundProcurement.length * 15
score += foundProducts.length * 10
score += foundBuyers.length * 12
score -= foundNegatives.length * 20
score = Math.max(0, Math.min(100, score))
// Determine importance label
let importanceLabel: ImportanceLabel = 'INFO'
if (score >= 80) importanceLabel = 'KRITISCH'
else if (score >= 65) importanceLabel = 'DRINGEND'
else if (score >= 50) importanceLabel = 'WICHTIG'
else if (score >= 30) importanceLabel = 'PRUEFEN'
// Determine decision label
let decisionLabel: DecisionLabel = 'irrelevant'
let decisionConfidence = 0.5
if (foundNegatives.length > 1) {
decisionLabel = 'irrelevant'
decisionConfidence = 0.8
} else if (foundProcurement.length >= 2 && foundProducts.length >= 1) {
decisionLabel = 'relevant'
decisionConfidence = 0.85
} else if (foundProcurement.length >= 1 || foundProducts.length >= 1) {
decisionLabel = 'needs_review'
decisionConfidence = 0.6
}
// Try to guess buyer and country
let buyerGuess: string | undefined
let countryGuess: string | undefined
const countryPatterns = [
{ pattern: /deutschland|germany|german/i, country: 'DE' },
{ pattern: /österreich|austria|austrian/i, country: 'AT' },
{ pattern: /schweiz|switzerland|swiss/i, country: 'CH' },
{ pattern: /frankreich|france|french/i, country: 'FR' },
{ pattern: /niederlande|netherlands|dutch/i, country: 'NL' },
]
for (const { pattern, country } of countryPatterns) {
if (pattern.test(emailContent)) {
countryGuess = country
break
}
}
// Extract potential buyer name (look for "Stadt X" or "Kommune Y")
const buyerMatch = emailContent.match(/(?:stadt|kommune|gemeinde|city of|municipality of)\s+([A-Za-zäöüß]+)/i)
if (buyerMatch) {
buyerGuess = buyerMatch[0]
}
// Try to find deadline
let deadlineGuess: string | undefined
const dateMatch = emailContent.match(/(\d{1,2})[.\/](\d{1,2})[.\/](\d{2,4})/);
if (dateMatch) {
const day = parseInt(dateMatch[1])
const month = parseInt(dateMatch[2])
const year = dateMatch[3].length === 2 ? 2000 + parseInt(dateMatch[3]) : parseInt(dateMatch[3])
const date = new Date(year, month - 1, day)
if (date > new Date()) {
deadlineGuess = date.toISOString()
}
}
// Create new hit
const newHit: B2BHit = {
id: `manual_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
tenantId: tenant.id,
topicId: topics[0]?.id || 'default',
sourceType: 'email',
sourceRef: 'manual_import',
originalUrl: firstUrl,
canonicalUrl: firstUrl,
title,
snippet,
fullText: emailContent,
foundAt: new Date(),
language: 'de',
countryGuess,
buyerGuess,
deadlineGuess,
importanceScore: score,
importanceLabel,
decisionLabel,
decisionConfidence,
decisionTrace: {
rulesTriggered: [
...(foundProcurement.length > 0 ? ['procurement_signal_detected'] : []),
...(foundProducts.length > 0 ? ['product_match_found'] : []),
...(foundBuyers.length > 0 ? ['public_buyer_signal'] : []),
...(foundNegatives.length > 0 ? ['negative_signal_detected'] : []),
],
llmUsed: true,
llmConfidence: decisionConfidence,
signals: {
procurementSignalsFound: foundProcurement,
publicBuyerSignalsFound: foundBuyers,
productSignalsFound: foundProducts,
negativesFound: foundNegatives
}
},
isRead: false,
createdAt: new Date()
}
// Add to hits (prepend to show at top)
setHits(prev => [newHit, ...prev])
return newHit
}, [tenant.id, topics])
// Digest
const getDigest = useCallback((maxItems: number = settings.maxDigestItems) => {
return hits
.filter(h => h.decisionLabel === 'relevant' || h.decisionLabel === 'needs_review')
.sort((a, b) => b.importanceScore - a.importanceScore)
.slice(0, maxItems)
}, [hits, settings.maxDigestItems])
// Computed values
const unreadCount = hits.filter(h => !h.isRead && h.decisionLabel !== 'irrelevant').length
const relevantCount = hits.filter(h => h.decisionLabel === 'relevant').length
const needsReviewCount = hits.filter(h => h.decisionLabel === 'needs_review').length
// SSR safety
if (!mounted) {
return (
<AlertsB2BContext.Provider
value={{
tenant: defaultTenant,
updateTenant: () => {},
settings: defaultB2BSettings,
updateSettings: () => {},
availableTemplates: [],
selectedTemplate: null,
selectTemplate: () => {},
sources: [],
addSource: () => {},
removeSource: () => {},
generateInboundEmail: () => '',
topics: [],
addTopic: () => {},
updateTopic: () => {},
removeTopic: () => {},
hits: [],
unreadCount: 0,
relevantCount: 0,
needsReviewCount: 0,
markAsRead: () => {},
markAllAsRead: () => {},
submitFeedback: () => {},
processEmailContent: () => ({} as B2BHit),
getDigest: () => [],
isLoading: false,
error: null
}}
>
{children}
</AlertsB2BContext.Provider>
)
}
return (
<AlertsB2BContext.Provider
value={{
tenant,
updateTenant,
settings,
updateSettings,
availableTemplates,
selectedTemplate,
selectTemplate,
sources,
addSource,
removeSource,
generateInboundEmail,
topics,
addTopic,
updateTopic,
removeTopic,
hits,
unreadCount,
relevantCount,
needsReviewCount,
markAsRead,
markAllAsRead,
submitFeedback,
processEmailContent,
getDigest,
isLoading,
error
}}
>
{children}
</AlertsB2BContext.Provider>
)
}
export function useAlertsB2B() {
const context = useContext(AlertsB2BContext)
if (!context) {
throw new Error('useAlertsB2B must be used within an AlertsB2BProvider')
}
return context
}
// ============================================
// HELPER FUNCTIONS
// ============================================
export function getImportanceLabelColor(label: ImportanceLabel, isDark: boolean): string {
const colors = {
'KRITISCH': isDark ? 'bg-red-500/20 text-red-300 border-red-500/30' : 'bg-red-100 text-red-700 border-red-200',
'DRINGEND': isDark ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' : 'bg-orange-100 text-orange-700 border-orange-200',
'WICHTIG': isDark ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' : 'bg-yellow-100 text-yellow-700 border-yellow-200',
'PRUEFEN': isDark ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' : 'bg-blue-100 text-blue-700 border-blue-200',
'INFO': isDark ? 'bg-slate-500/20 text-slate-300 border-slate-500/30' : 'bg-slate-100 text-slate-600 border-slate-200',
}
return colors[label]
}
export function getDecisionLabelColor(label: DecisionLabel, isDark: boolean): string {
const colors = {
'relevant': isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700',
'irrelevant': isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-slate-100 text-slate-500',
'needs_review': isDark ? 'bg-amber-500/20 text-amber-300' : 'bg-amber-100 text-amber-700',
}
return colors[label]
}
export function formatDeadline(dateStr: string | null | undefined): string {
if (!dateStr) return 'Keine Frist'
const date = new Date(dateStr)
const now = new Date()
const diffDays = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) return 'Abgelaufen'
if (diffDays === 0) return 'Heute!'
if (diffDays === 1) return 'Morgen'
if (diffDays <= 7) return `${diffDays} Tage`
if (diffDays <= 30) return `${Math.ceil(diffDays / 7)} Wochen`
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
export function getPackageIcon(pkg: Package): string {
const icons = {
'PARKING': '🅿️',
'EV_CHARGING': '⚡',
'FUEL': '⛽',
'TANK_MONITORING': '📊'
}
return icons[pkg]
}
export function getPackageLabel(pkg: Package): string {
const labels = {
'PARKING': 'Parking',
'EV_CHARGING': 'EV Charging',
'FUEL': 'Fuel',
'TANK_MONITORING': 'Tank Monitoring'
}
return labels[pkg]
}