feat: Add LeadsView with role-based filtering
- Users see only their own leads - Manager/Admin/CEO see all leads - Filter by owner dropdown for managers - Lead source field - Mobile-responsive cards
This commit is contained in:
@@ -26,7 +26,7 @@ const routes = [
|
||||
{
|
||||
path: 'leads',
|
||||
name: 'Leads',
|
||||
component: () => import('@/views/ContactsView.vue') // Uses contacts view for now
|
||||
component: () => import('@/views/LeadsView.vue')
|
||||
},
|
||||
{
|
||||
path: 'contacts',
|
||||
|
||||
401
src/views/LeadsView.vue
Normal file
401
src/views/LeadsView.vue
Normal file
@@ -0,0 +1,401 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import api from '@/lib/api'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const leads = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const filterOwner = ref('all') // 'all' or 'mine' or specific user id
|
||||
const teamMembers = ref([])
|
||||
const meta = ref({ total: 0 })
|
||||
|
||||
// Modal state
|
||||
const showNewModal = ref(false)
|
||||
const newLead = ref({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
company: '',
|
||||
leadSource: '',
|
||||
})
|
||||
|
||||
// Check if user can see all leads
|
||||
const canSeeAll = computed(() => {
|
||||
return ['owner', 'admin', 'manager'].includes(auth.user?.role)
|
||||
})
|
||||
|
||||
// Lead sources for dropdown
|
||||
const leadSources = [
|
||||
'Website',
|
||||
'Empfehlung',
|
||||
'Kaltakquise',
|
||||
'Social Media',
|
||||
'Messe',
|
||||
'Werbung',
|
||||
'Sonstiges'
|
||||
]
|
||||
|
||||
async function fetchLeads() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (search.value) params.append('search', search.value)
|
||||
|
||||
// If user can't see all, always filter by owner
|
||||
if (!canSeeAll.value) {
|
||||
params.append('ownerId', auth.user.id)
|
||||
} else if (filterOwner.value === 'mine') {
|
||||
params.append('ownerId', auth.user.id)
|
||||
} else if (filterOwner.value !== 'all') {
|
||||
params.append('ownerId', filterOwner.value)
|
||||
}
|
||||
|
||||
const res = await api.get(`/contacts?${params.toString()}`)
|
||||
leads.value = res.data.data.contacts
|
||||
meta.value = res.data.data.meta
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch leads:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTeamMembers() {
|
||||
if (!canSeeAll.value) return
|
||||
try {
|
||||
const res = await api.get('/users')
|
||||
teamMembers.value = res.data.data.users
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch team:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function createLead() {
|
||||
if (!newLead.value.firstName || !newLead.value.lastName) return
|
||||
|
||||
try {
|
||||
const res = await api.post('/contacts', newLead.value)
|
||||
showNewModal.value = false
|
||||
newLead.value = { firstName: '', lastName: '', email: '', phone: '', company: '', leadSource: '' }
|
||||
router.push(`/contacts/${res.data.data.contact.id}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to create lead:', error)
|
||||
alert('Fehler beim Erstellen')
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(lead) {
|
||||
return (lead.firstName?.[0] || '') + (lead.lastName?.[0] || '')
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString('de-DE')
|
||||
}
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout
|
||||
watch(search, () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(fetchLeads, 300)
|
||||
})
|
||||
|
||||
watch(filterOwner, fetchLeads)
|
||||
|
||||
onMounted(() => {
|
||||
fetchLeads()
|
||||
fetchTeamMembers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 sm:p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-white">Leads</h1>
|
||||
<p class="text-pulse-muted text-sm">
|
||||
{{ meta.total }} Leads
|
||||
<span v-if="!canSeeAll" class="text-primary-400">(Meine)</span>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="showNewModal = true" class="btn-primary w-full sm:w-auto">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neuer Lead
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<!-- Search -->
|
||||
<div class="relative flex-1 sm:max-w-md">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-pulse-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="input pl-10 w-full"
|
||||
placeholder="Suchen..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Owner Filter (for managers+) -->
|
||||
<div v-if="canSeeAll" class="flex items-center gap-2">
|
||||
<label class="text-sm text-pulse-muted whitespace-nowrap">Zuständig:</label>
|
||||
<select v-model="filterOwner" class="input py-2 min-w-[150px]">
|
||||
<option value="all">Alle</option>
|
||||
<option value="mine">Meine</option>
|
||||
<option v-for="member in teamMembers" :key="member.id" :value="member.id">
|
||||
{{ member.firstName }} {{ member.lastName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="leads.length === 0" class="text-center py-12">
|
||||
<div class="text-5xl mb-4">📋</div>
|
||||
<h3 class="text-xl font-medium text-white mb-2">Keine Leads gefunden</h3>
|
||||
<p class="text-pulse-muted mb-4">Erstelle deinen ersten Lead, um loszulegen.</p>
|
||||
<button @click="showNewModal = true" class="btn-primary">
|
||||
Neuer Lead
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Table -->
|
||||
<div v-else class="card hidden md:block">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-pulse-border">
|
||||
<th class="text-left text-sm font-medium text-pulse-muted px-4 py-3">Name</th>
|
||||
<th class="text-left text-sm font-medium text-pulse-muted px-4 py-3">E-Mail</th>
|
||||
<th class="text-left text-sm font-medium text-pulse-muted px-4 py-3">Telefon</th>
|
||||
<th class="text-left text-sm font-medium text-pulse-muted px-4 py-3">Firma</th>
|
||||
<th class="text-left text-sm font-medium text-pulse-muted px-4 py-3">Quelle</th>
|
||||
<th v-if="canSeeAll" class="text-left text-sm font-medium text-pulse-muted px-4 py-3">Zuständig</th>
|
||||
<th class="text-left text-sm font-medium text-pulse-muted px-4 py-3">Erstellt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="lead in leads"
|
||||
:key="lead.id"
|
||||
class="border-b border-pulse-border/50 hover:bg-pulse-border/30 cursor-pointer transition-colors"
|
||||
@click="router.push(`/contacts/${lead.id}`)"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-full bg-primary-600 flex items-center justify-center text-white font-medium text-sm flex-shrink-0">
|
||||
{{ getInitials(lead) }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-white">{{ lead.firstName }} {{ lead.lastName }}</div>
|
||||
<div v-if="lead.jobTitle" class="text-sm text-pulse-muted">{{ lead.jobTitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-pulse-muted">{{ lead.email || '-' }}</td>
|
||||
<td class="px-4 py-3 text-pulse-muted">{{ lead.phone || '-' }}</td>
|
||||
<td class="px-4 py-3 text-pulse-muted">{{ lead.companyName || '-' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span v-if="lead.leadSource" class="px-2 py-1 bg-pulse-border rounded text-xs text-pulse-muted">
|
||||
{{ lead.leadSource }}
|
||||
</span>
|
||||
<span v-else class="text-pulse-muted">-</span>
|
||||
</td>
|
||||
<td v-if="canSeeAll" class="px-4 py-3 text-pulse-muted">
|
||||
{{ lead.ownerName || '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-pulse-muted text-sm">{{ formatDate(lead.createdAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Cards -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<div
|
||||
v-for="lead in leads"
|
||||
:key="lead.id"
|
||||
class="card p-4 cursor-pointer hover:border-primary-500/50 transition-colors"
|
||||
@click="router.push(`/contacts/${lead.id}`)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-12 h-12 rounded-full bg-primary-600 flex items-center justify-center text-white font-medium flex-shrink-0">
|
||||
{{ getInitials(lead) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-white">{{ lead.firstName }} {{ lead.lastName }}</div>
|
||||
<div v-if="lead.companyName" class="text-sm text-pulse-muted">{{ lead.companyName }}</div>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<span v-if="lead.leadSource" class="px-2 py-0.5 bg-pulse-border rounded text-xs text-pulse-muted">
|
||||
{{ lead.leadSource }}
|
||||
</span>
|
||||
<span v-if="canSeeAll && lead.ownerName" class="px-2 py-0.5 bg-primary-500/20 rounded text-xs text-primary-400">
|
||||
{{ lead.ownerName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Lead Modal -->
|
||||
<div
|
||||
v-if="showNewModal"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="showNewModal = false"
|
||||
>
|
||||
<div class="bg-pulse-card rounded-xl border border-pulse-border w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6 border-b border-pulse-border">
|
||||
<h2 class="text-xl font-bold text-white">Neuer Lead</h2>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="createLead" class="p-6 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-pulse-muted mb-2">Vorname *</label>
|
||||
<input
|
||||
v-model="newLead.firstName"
|
||||
type="text"
|
||||
required
|
||||
class="input w-full"
|
||||
placeholder="Max"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-pulse-muted mb-2">Nachname *</label>
|
||||
<input
|
||||
v-model="newLead.lastName"
|
||||
type="text"
|
||||
required
|
||||
class="input w-full"
|
||||
placeholder="Mustermann"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-pulse-muted mb-2">E-Mail</label>
|
||||
<input
|
||||
v-model="newLead.email"
|
||||
type="email"
|
||||
class="input w-full"
|
||||
placeholder="max@beispiel.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-pulse-muted mb-2">Telefon</label>
|
||||
<input
|
||||
v-model="newLead.phone"
|
||||
type="tel"
|
||||
class="input w-full"
|
||||
placeholder="+49 123 456789"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-pulse-muted mb-2">Firma</label>
|
||||
<input
|
||||
v-model="newLead.company"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
placeholder="Beispiel GmbH"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-pulse-muted mb-2">Lead-Quelle</label>
|
||||
<select v-model="newLead.leadSource" class="input w-full">
|
||||
<option value="">Auswählen...</option>
|
||||
<option v-for="source in leadSources" :key="source" :value="source">
|
||||
{{ source }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="showNewModal = false"
|
||||
class="btn-secondary"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn-primary">
|
||||
Lead erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: rgb(99 102 241);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: rgb(79 70 229);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: rgb(55 65 81);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: rgb(75 85 99);
|
||||
}
|
||||
|
||||
.input {
|
||||
background-color: rgb(17 24 39);
|
||||
border: 1px solid rgb(55 65 81);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: white;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.input::placeholder {
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: rgb(99 102 241);
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: rgb(31 41 55);
|
||||
border: 1px solid rgb(55 65 81);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user