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>
1166 lines
38 KiB
TypeScript
1166 lines
38 KiB
TypeScript
'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]
|
||
}
|