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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<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>
|
<script type="module" crossorigin src="/assets/index-RK4T25Mp.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C4-IO3Fe.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Dz2nBDKI.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import { MarketingPage } from '@/pages/MarketingPage'
|
|||||||
import { SupportPage } from '@/pages/SupportPage'
|
import { SupportPage } from '@/pages/SupportPage'
|
||||||
import { AutomationPage } from '@/pages/AutomationPage'
|
import { AutomationPage } from '@/pages/AutomationPage'
|
||||||
import { AgentLayerPage } from '@/pages/AgentLayerPage'
|
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 }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
@@ -41,6 +44,9 @@ export default function App() {
|
|||||||
<Route path="/support" element={<SupportPage />} />
|
<Route path="/support" element={<SupportPage />} />
|
||||||
<Route path="/automation" element={<AutomationPage />} />
|
<Route path="/automation" element={<AutomationPage />} />
|
||||||
<Route path="/agents" element={<AgentLayerPage />} />
|
<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="/legal" element={<LegalPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
Bot,
|
Bot,
|
||||||
|
Inbox,
|
||||||
|
Target,
|
||||||
|
MessageCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -22,6 +25,9 @@ const navItems = [
|
|||||||
{ path: '/', label: 'Briefing', icon: Newspaper },
|
{ path: '/', label: 'Briefing', icon: Newspaper },
|
||||||
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ path: '/agents', label: 'Agents', icon: Bot },
|
{ 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: '/crm', label: 'CRM', icon: Users },
|
||||||
{ path: '/sales', label: 'Sales', icon: TrendingUp },
|
{ path: '/sales', label: 'Sales', icon: TrendingUp },
|
||||||
{ path: '/finance', label: 'Finance', icon: Wallet },
|
{ 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