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
+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>
)
}