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:
FluxKit
2026-02-25 13:30:59 +00:00
parent 7b0fd718b0
commit 3d2c4e2f26
2 changed files with 402 additions and 1 deletions

View File

@@ -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
View 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>