feat(comm): add frontend UI for Communication & Lead Layer
- 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:
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+157
-142
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user