feat(comm): add frontend UI for Communication & Lead Layer
BOC CI/CD / Test (push) Failing after 2s
BOC CI/CD / Security Scan (push) Has been skipped
BOC CI/CD / Build & Push (push) Has been skipped
BOC CI/CD / Deploy to Staging (push) Has been skipped
BOC CI/CD / Deploy to Production (push) Has been skipped

- Add CommInboxPage with conversation list and message view
- Add CommLeadsPage with pipeline visualization and lead scoring
- Add CommContactsPage with search and filtering
- Add navigation routes for /comm/inbox, /comm/leads, /comm/contacts
- Update Sidebar with Communication menu items
- Build fresh web-v2 dist
This commit is contained in:
Bernt
2026-08-12 11:26:19 +00:00
parent 718471519d
commit 2f1b020a1d
9 changed files with 871 additions and 145 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-CZzJtMVa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C4-IO3Fe.css">
<script type="module" crossorigin src="/assets/index-RK4T25Mp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dz2nBDKI.css">
</head>
<body>
<div id="root"></div>
+6
View File
@@ -14,6 +14,9 @@ import { MarketingPage } from '@/pages/MarketingPage'
import { SupportPage } from '@/pages/SupportPage'
import { AutomationPage } from '@/pages/AutomationPage'
import { AgentLayerPage } from '@/pages/AgentLayerPage'
import { CommInboxPage } from '@/pages/CommInboxPage'
import { CommLeadsPage } from '@/pages/CommLeadsPage'
import { CommContactsPage } from '@/pages/CommContactsPage'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore()
@@ -41,6 +44,9 @@ export default function App() {
<Route path="/support" element={<SupportPage />} />
<Route path="/automation" element={<AutomationPage />} />
<Route path="/agents" element={<AgentLayerPage />} />
<Route path="/comm/inbox" element={<CommInboxPage />} />
<Route path="/comm/leads" element={<CommLeadsPage />} />
<Route path="/comm/contacts" element={<CommContactsPage />} />
<Route path="/legal" element={<LegalPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
+6
View File
@@ -15,6 +15,9 @@ import {
ChevronRight,
Newspaper,
Bot,
Inbox,
Target,
MessageCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -22,6 +25,9 @@ const navItems = [
{ path: '/', label: 'Briefing', icon: Newspaper },
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/agents', label: 'Agents', icon: Bot },
{ path: '/comm/inbox', label: 'Inbox', icon: Inbox },
{ path: '/comm/leads', label: 'Leads', icon: Target },
{ path: '/comm/contacts', label: 'Contacts', icon: MessageCircle },
{ path: '/crm', label: 'CRM', icon: Users },
{ path: '/sales', label: 'Sales', icon: TrendingUp },
{ path: '/finance', label: 'Finance', icon: Wallet },
+176
View File
@@ -0,0 +1,176 @@
import { useState, useEffect } from 'react'
import { Card } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Badge } from '@/components/ui/Badge'
import { Skeleton } from '@/components/ui/Skeleton'
import { useAuthStore } from '@/stores/authStore'
import {
Users,
Search,
Mail,
Phone,
Building2,
Tag,
MoreHorizontal,
Plus,
} from 'lucide-react'
interface Contact {
id: string
first_name: string
last_name: string
email: string
phone: string
company: string
tags: string[]
status: string
lead_score: number
source: string
created_at: string
}
export function CommContactsPage() {
const { token } = useAuthStore()
const [contacts, setContacts] = useState<Contact[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
useEffect(() => {
fetchContacts()
}, [])
const fetchContacts = async () => {
try {
const res = await fetch('/api/v1/comm/contacts', {
headers: { Authorization: `Bearer ${token}` }
})
const data = await res.json()
setContacts(data.contacts || [])
} catch (err) {
console.error('Failed to fetch contacts:', err)
} finally {
setLoading(false)
}
}
const filteredContacts = contacts.filter(c =>
`${c.first_name} ${c.last_name}`.toLowerCase().includes(search.toLowerCase()) ||
c.email?.toLowerCase().includes(search.toLowerCase()) ||
c.company?.toLowerCase().includes(search.toLowerCase())
)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">Kontakter</h1>
<p className="text-sm text-text-secondary mt-1">Hantera alla kontakter</p>
</div>
<Button className="flex items-center gap-2">
<Plus size={16} />
Ny kontakt
</Button>
</div>
{/* Search */}
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Sök kontakter..."
className="w-full pl-10 pr-4 py-2.5 rounded-xl border bg-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<Card className="p-4">
<p className="text-xs text-text-secondary">Totalt</p>
<p className="text-2xl font-semibold text-text-primary mt-1">{contacts.length}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-text-secondary">Aktiva</p>
<p className="text-2xl font-semibold text-success mt-1">
{contacts.filter(c => c.status === 'active').length}
</p>
</Card>
<Card className="p-4">
<p className="text-xs text-text-secondary">Med leads</p>
<p className="text-2xl font-semibold text-primary mt-1">
{contacts.filter(c => c.lead_score > 0).length}
</p>
</Card>
</div>
{/* Contacts list */}
<div className="space-y-3">
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-20" />
))
) : filteredContacts.length === 0 ? (
<div className="text-center py-12 text-text-secondary">
<Users size={48} className="mx-auto mb-4 opacity-50" />
<p>Inga kontakter hittades</p>
</div>
) : (
filteredContacts.map((contact) => (
<Card key={contact.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-primary/10 text-primary flex items-center justify-center text-lg font-semibold">
{contact.first_name?.charAt(0) || '?'}
</div>
<div>
<p className="font-medium text-text-primary">
{contact.first_name} {contact.last_name}
</p>
<div className="flex items-center gap-3 mt-1 text-sm text-text-secondary">
{contact.email && (
<span className="flex items-center gap-1">
<Mail size={12} />
{contact.email}
</span>
)}
{contact.phone && (
<span className="flex items-center gap-1">
<Phone size={12} />
{contact.phone}
</span>
)}
{contact.company && (
<span className="flex items-center gap-1">
<Building2 size={12} />
{contact.company}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-2">
{contact.tags?.map((tag) => (
<Badge key={tag} variant="default" className="text-xs">
<Tag size={10} className="mr-1" />
{tag}
</Badge>
))}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={contact.status === 'active' ? 'success' : 'default'}>
{contact.status}
</Badge>
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</div>
</div>
</Card>
))
)}
</div>
</div>
)
}
+283
View File
@@ -0,0 +1,283 @@
import { useState, useEffect } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Badge } from '@/components/ui/Badge'
import { Skeleton } from '@/components/ui/Skeleton'
import { useAuthStore } from '@/stores/authStore'
import {
Inbox,
MessageCircle,
Clock,
CheckCircle2,
AlertCircle,
User,
Send,
Filter,
Search,
MoreHorizontal,
} from 'lucide-react'
interface Conversation {
id: string
contact_id: string
channel: string
status: string
priority: string
contact?: {
first_name: string
last_name: string
email: string
phone: string
}
message_count: number
unread_count: number
last_activity_at: string
created_at: string
}
export function CommInboxPage() {
const { token } = useAuthStore()
const [conversations, setConversations] = useState<Conversation[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('all')
const [selectedConv, setSelectedConv] = useState<Conversation | null>(null)
const [messages, setMessages] = useState<any[]>([])
const [newMessage, setNewMessage] = useState('')
useEffect(() => {
fetchConversations()
}, [filter])
const fetchConversations = async () => {
try {
const res = await fetch(`/api/v1/comm/conversations${filter !== 'all' ? `?status=${filter}` : ''}`, {
headers: { Authorization: `Bearer ${token}` }
})
const data = await res.json()
setConversations(data.conversations || [])
} catch (err) {
console.error('Failed to fetch conversations:', err)
} finally {
setLoading(false)
}
}
const fetchMessages = async (convId: string) => {
try {
const res = await fetch(`/api/v1/comm/conversations/${convId}`, {
headers: { Authorization: `Bearer ${token}` }
})
const data = await res.json()
setMessages(data.messages || [])
} catch (err) {
console.error('Failed to fetch messages:', err)
}
}
const sendMessage = async () => {
if (!selectedConv || !newMessage.trim()) return
try {
await fetch(`/api/v1/comm/conversations/${selectedConv.id}/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
content: newMessage,
direction: 'outbound',
sender_type: 'user'
})
})
setNewMessage('')
fetchMessages(selectedConv.id)
} catch (err) {
console.error('Failed to send message:', err)
}
}
const getStatusIcon = (status: string) => {
switch (status) {
case 'new': return <AlertCircle size={14} className="text-warning" />
case 'active': return <MessageCircle size={14} className="text-primary" />
case 'resolved': return <CheckCircle2 size={14} className="text-success" />
default: return <Clock size={14} className="text-text-secondary" />
}
}
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'urgent': return 'danger'
case 'high': return 'warning'
case 'low': return 'default'
default: return 'primary'
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">Inbox</h1>
<p className="text-sm text-text-secondary mt-1">Hantera inkommande konversationer</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" className="flex items-center gap-2">
<Filter size={16} />
Filter
</Button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: 'Nya', count: conversations.filter(c => c.status === 'new').length, color: 'warning' },
{ label: 'Aktiva', count: conversations.filter(c => c.status === 'active').length, color: 'primary' },
{ label: 'Väntande', count: conversations.filter(c => c.status === 'waiting').length, color: 'default' },
{ label: 'Avslutade', count: conversations.filter(c => c.status === 'resolved').length, color: 'success' },
].map((stat) => (
<Card key={stat.label} className="p-4">
<p className="text-xs text-text-secondary">{stat.label}</p>
<p className={`text-2xl font-semibold text-${stat.color} mt-1`}>{stat.count}</p>
</Card>
))}
</div>
{/* Filter tabs */}
<div className="flex gap-2 border-b border-border pb-2">
{['all', 'new', 'active', 'waiting', 'resolved'].map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
filter === f
? 'bg-primary text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-bg'
}`}
>
{f === 'all' ? 'Alla' : f === 'new' ? 'Nya' : f === 'active' ? 'Aktiva' : f === 'waiting' ? 'Väntande' : 'Avslutade'}
</button>
))}
</div>
{/* Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Conversation list */}
<div className="lg:col-span-1 space-y-3">
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-20" />
))
) : conversations.length === 0 ? (
<div className="text-center py-12 text-text-secondary">
<Inbox size={48} className="mx-auto mb-4 opacity-50" />
<p>Inga konversationer</p>
</div>
) : (
conversations.map((conv) => (
<div
key={conv.id}
onClick={() => {
setSelectedConv(conv)
fetchMessages(conv.id)
}}
className={`p-4 rounded-xl border cursor-pointer transition-colors ${
selectedConv?.id === conv.id
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/30'
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 text-primary flex items-center justify-center text-sm font-semibold">
{conv.contact?.first_name?.charAt(0) || '?'}
</div>
<div>
<p className="font-medium text-text-primary">
{conv.contact?.first_name} {conv.contact?.last_name}
</p>
<p className="text-xs text-text-secondary">{conv.channel}</p>
</div>
</div>
<div className="flex items-center gap-2">
{conv.unread_count > 0 && (
<Badge variant="danger">{conv.unread_count}</Badge>
)}
{getStatusIcon(conv.status)}
</div>
</div>
<div className="flex items-center justify-between mt-2">
<Badge variant={getPriorityColor(conv.priority)}>{conv.priority}</Badge>
<span className="text-xs text-text-secondary">
{new Date(conv.last_activity_at).toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
))
)}
</div>
{/* Message view */}
<div className="lg:col-span-2">
{selectedConv ? (
<Card className="h-[600px] flex flex-col">
<CardHeader
title={`${selectedConv.contact?.first_name} ${selectedConv.contact?.last_name}`}
subtitle={`${selectedConv.channel}${selectedConv.status}`}
/>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.direction === 'outbound' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] px-4 py-2 rounded-xl ${
msg.direction === 'outbound'
? 'bg-primary text-white'
: 'bg-bg text-text-primary'
}`}
>
<p className="text-sm">{msg.content}</p>
<p className={`text-xs mt-1 ${msg.direction === 'outbound' ? 'text-white/70' : 'text-text-secondary'}`}>
{new Date(msg.created_at).toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
))}
</div>
{/* Input */}
<div className="p-4 border-t border-border">
<div className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Skriv ett meddelande..."
className="flex-1 px-4 py-2 rounded-xl border bg-surface text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
<Button onClick={sendMessage} disabled={!newMessage.trim()}>
<Send size={16} />
</Button>
</div>
</div>
</Card>
) : (
<div className="h-[600px] flex items-center justify-center text-text-secondary">
<div className="text-center">
<MessageCircle size={48} className="mx-auto mb-4 opacity-50" />
<p>Välj en konversation för att visa meddelanden</p>
</div>
</div>
)}
</div>
</div>
</div>
)
}
+240
View File
@@ -0,0 +1,240 @@
import { useState, useEffect } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Badge } from '@/components/ui/Badge'
import { Skeleton } from '@/components/ui/Skeleton'
import { useAuthStore } from '@/stores/authStore'
import {
Target,
TrendingUp,
Filter,
Search,
User,
Calendar,
Tag,
MoreHorizontal,
ArrowRight,
} from 'lucide-react'
interface Lead {
id: string
contact_id: string
source: string
status: string
interest: string
product: string
urgency: string
lead_score: number
qualification_complete: boolean
tags: string[]
created_at: string
contact?: {
first_name: string
last_name: string
email: string
phone: string
}
}
export function CommLeadsPage() {
const { token } = useAuthStore()
const [leads, setLeads] = useState<Lead[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('all')
const [selectedLead, setSelectedLead] = useState<Lead | null>(null)
useEffect(() => {
fetchLeads()
}, [filter])
const fetchLeads = async () => {
try {
const url = filter === 'all'
? '/api/v1/comm/leads'
: `/api/v1/comm/leads?status=${filter}`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }
})
const data = await res.json()
setLeads(data.leads || [])
} catch (err) {
console.error('Failed to fetch leads:', err)
} finally {
setLoading(false)
}
}
const getScoreColor = (score: number) => {
if (score >= 81) return 'danger' // HOT
if (score >= 61) return 'warning' // HIGH
if (score >= 31) return 'primary' // MEDIUM
return 'default' // LOW
}
const getScoreLabel = (score: number) => {
if (score >= 81) return 'HOT'
if (score >= 61) return 'HIGH'
if (score >= 31) return 'MEDIUM'
return 'LOW'
}
const getStatusColor = (status: string) => {
switch (status) {
case 'new': return 'default'
case 'contacted': return 'primary'
case 'qualifying': return 'warning'
case 'qualified': return 'success'
case 'sales': return 'primary'
case 'won': return 'success'
case 'lost': return 'danger'
default: return 'default'
}
}
const pipelineStages = [
{ key: 'new', label: 'NEW', color: 'bg-text-secondary' },
{ key: 'contacted', label: 'CONTACTED', color: 'bg-primary' },
{ key: 'qualifying', label: 'QUALIFYING', color: 'bg-warning' },
{ key: 'qualified', label: 'QUALIFIED', color: 'bg-success' },
{ key: 'sales', label: 'SALES', color: 'bg-primary' },
{ key: 'won', label: 'WON', color: 'bg-success' },
{ key: 'lost', label: 'LOST', color: 'bg-danger' },
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">Leads</h1>
<p className="text-sm text-text-secondary mt-1">Hantera och kvalificera leads</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" className="flex items-center gap-2">
<Filter size={16} />
Filter
</Button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{[
{ label: 'Totalt', count: leads.length, color: 'text-text-primary' },
{ label: 'Nya', count: leads.filter(l => l.status === 'new').length, color: 'text-primary' },
{ label: 'Kvalificerade', count: leads.filter(l => l.status === 'qualified').length, color: 'text-success' },
{ label: 'HOT', count: leads.filter(l => l.lead_score >= 81).length, color: 'text-danger' },
{ label: 'Vunna', count: leads.filter(l => l.status === 'won').length, color: 'text-success' },
].map((stat) => (
<Card key={stat.label} className="p-4">
<p className="text-xs text-text-secondary">{stat.label}</p>
<p className={`text-2xl font-semibold ${stat.color} mt-1`}>{stat.count}</p>
</Card>
))}
</div>
{/* Pipeline */}
<Card className="p-6">
<h3 className="text-sm font-medium text-text-primary mb-4">Pipeline</h3>
<div className="flex items-center gap-2 overflow-x-auto pb-2">
{pipelineStages.map((stage, index) => {
const count = leads.filter(l => l.status === stage.key).length
return (
<div key={stage.key} className="flex items-center gap-2">
<div className={`px-4 py-2 rounded-lg ${stage.color} text-white text-xs font-medium min-w-[80px] text-center`}>
{stage.label}
<div className="text-lg font-bold mt-1">{count}</div>
</div>
{index < pipelineStages.length - 1 && (
<ArrowRight size={16} className="text-text-secondary flex-shrink-0" />
)}
</div>
)
})}
</div>
</Card>
{/* Filter tabs */}
<div className="flex gap-2 border-b border-border pb-2">
{['all', 'new', 'contacted', 'qualifying', 'qualified', 'sales', 'won', 'lost'].map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
filter === f
? 'bg-primary text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-bg'
}`}
>
{f.toUpperCase()}
</button>
))}
</div>
{/* Leads list */}
<div className="space-y-3">
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-24" />
))
) : leads.length === 0 ? (
<div className="text-center py-12 text-text-secondary">
<Target size={48} className="mx-auto mb-4 opacity-50" />
<p>Inga leads</p>
</div>
) : (
leads.map((lead) => (
<Card
key={lead.id}
className="p-4 cursor-pointer hover:border-primary/30 transition-colors"
onClick={() => setSelectedLead(lead)}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-primary/10 text-primary flex items-center justify-center text-lg font-semibold">
{lead.contact?.first_name?.charAt(0) || '?'}
</div>
<div>
<div className="flex items-center gap-2">
<p className="font-medium text-text-primary">
{lead.contact?.first_name} {lead.contact?.last_name}
</p>
<Badge variant={getScoreColor(lead.lead_score)}>
{getScoreLabel(lead.lead_score)} {lead.lead_score}
</Badge>
</div>
<p className="text-sm text-text-secondary mt-0.5">
{lead.interest || 'Inget intresse angivet'}
</p>
<div className="flex items-center gap-3 mt-2">
<Badge variant={getStatusColor(lead.status)}>{lead.status}</Badge>
<span className="text-xs text-text-secondary flex items-center gap-1">
<Calendar size={12} />
{new Date(lead.created_at).toLocaleDateString('sv-SE')}
</span>
<span className="text-xs text-text-secondary flex items-center gap-1">
<Target size={12} />
{lead.source}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{lead.tags?.map((tag) => (
<Badge key={tag} variant="default" className="text-xs">
<Tag size={10} className="mr-1" />
{tag}
</Badge>
))}
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</div>
</div>
</Card>
))
)}
</div>
</div>
)
}