feat: Pulse CRM Frontend v1.0

Vue 3 + Vite + Tailwind CSS

Views:
- Dashboard mit Stats & Aktivitäten
- Kontakte mit Suche & CRUD
- Firmen mit Cards & CRUD
- Pipeline Kanban Board (Drag & Drop)
- Aktivitäten mit Filter & Timeline
- Settings mit DSGVO-Info

Features:
- Dark Theme (Pulse Design)
- Responsive Layout
- Pinia State Management
- Vue Router mit Guards
- Axios API Client

Task: #13 Frontend UI
This commit is contained in:
FluxKit
2026-02-11 11:24:04 +00:00
commit 01d542b6b6
29 changed files with 2609 additions and 0 deletions

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=

37
Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# Build stage
FROM node:20-alpine as build
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets
COPY --from=build /app/dist /usr/share/nginx/html
# Nginx config for SPA
RUN echo 'server { \
listen 80; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
} \
location /api { \
proxy_pass https://api.crm.kronos-soulution.de; \
proxy_http_version 1.1; \
proxy_set_header Host api.crm.kronos-soulution.de; \
proxy_set_header X-Real-IP $remote_addr; \
proxy_ssl_server_name on; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="de" class="h-full">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/pulse-icon.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Pulse CRM - Moderne Kundenbeziehungen">
<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@400;500;600;700&display=swap" rel="stylesheet">
<title>Pulse CRM</title>
</head>
<body class="h-full bg-pulse-dark text-pulse-text antialiased">
<div id="app" class="h-full"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "pulse-crm-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"pinia": "^2.1.0",
"@vueuse/core": "^10.7.0",
"axios": "^1.6.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

10
public/pulse-icon.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="pulse-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6"/>
<stop offset="100%" style="stop-color:#1d4ed8"/>
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="url(#pulse-gradient)"/>
<path d="M30 50 L45 35 L50 50 L55 25 L60 50 L70 50" stroke="white" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

21
src/App.vue Normal file
View File

@@ -0,0 +1,21 @@
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
onMounted(async () => {
if (auth.token) {
const success = await auth.fetchUser()
if (!success) {
router.push('/login')
}
}
})
</script>
<template>
<RouterView />
</template>

108
src/layouts/AppLayout.vue Normal file
View File

@@ -0,0 +1,108 @@
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const sidebarOpen = ref(true)
const navItems = [
{ name: 'Dashboard', path: '/', icon: 'chart-pie' },
{ name: 'Kontakte', path: '/contacts', icon: 'users' },
{ name: 'Firmen', path: '/companies', icon: 'building-office' },
{ name: 'Pipeline', path: '/pipeline', icon: 'funnel' },
{ name: 'Aktivitäten', path: '/activities', icon: 'clipboard-list' },
]
function logout() {
auth.logout()
router.push('/login')
}
function isActive(path) {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
</script>
<template>
<div class="flex h-full">
<!-- Sidebar -->
<aside
:class="[
'flex flex-col bg-pulse-card border-r border-pulse-border transition-all duration-300',
sidebarOpen ? 'w-64' : 'w-20'
]"
>
<!-- Logo -->
<div class="flex items-center gap-3 px-6 py-5 border-b border-pulse-border">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span v-if="sidebarOpen" class="text-xl font-bold text-white">Pulse</span>
</div>
<!-- Navigation -->
<nav class="flex-1 py-4 px-3 space-y-1">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
:class="['sidebar-link', isActive(item.path) && 'active']"
>
<!-- Icons -->
<svg v-if="item.icon === 'chart-pie'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />
</svg>
<svg v-if="item.icon === 'users'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<svg v-if="item.icon === 'building-office'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<svg v-if="item.icon === 'funnel'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
<svg v-if="item.icon === 'clipboard-list'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
<span v-if="sidebarOpen">{{ item.name }}</span>
</RouterLink>
</nav>
<!-- User -->
<div class="border-t border-pulse-border p-4">
<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">
{{ auth.user?.firstName?.[0] || 'U' }}
</div>
<div v-if="sidebarOpen" class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">
{{ auth.user?.firstName }} {{ auth.user?.lastName }}
</p>
<p class="text-xs text-pulse-muted truncate">{{ auth.user?.email }}</p>
</div>
<button
@click="logout"
class="p-2 text-pulse-muted hover:text-white transition-colors"
title="Abmelden"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
<RouterView />
</main>
</div>
</template>

23
src/lib/api.js Normal file
View File

@@ -0,0 +1,23 @@
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '',
timeout: 15000,
headers: {
'Content-Type': 'application/json'
}
})
// Response interceptor for error handling
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('pulse_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api

12
src/main.js Normal file
View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

86
src/router/index.js Normal file
View File

@@ -0,0 +1,86 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/LoginView.vue'),
meta: { guest: true }
},
{
path: '/',
component: () => import('@/layouts/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/DashboardView.vue')
},
{
path: 'contacts',
name: 'Contacts',
component: () => import('@/views/ContactsView.vue')
},
{
path: 'contacts/:id',
name: 'ContactDetail',
component: () => import('@/views/ContactDetailView.vue')
},
{
path: 'companies',
name: 'Companies',
component: () => import('@/views/CompaniesView.vue')
},
{
path: 'companies/:id',
name: 'CompanyDetail',
component: () => import('@/views/CompanyDetailView.vue')
},
{
path: 'pipeline',
name: 'Pipeline',
component: () => import('@/views/PipelineView.vue')
},
{
path: 'deals/:id',
name: 'DealDetail',
component: () => import('@/views/DealDetailView.vue')
},
{
path: 'activities',
name: 'Activities',
component: () => import('@/views/ActivitiesView.vue')
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/SettingsView.vue')
}
]
},
{
path: '/:pathMatch(.*)*',
redirect: '/'
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to, from, next) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
next('/login')
} else if (to.meta.guest && auth.isAuthenticated) {
next('/')
} else {
next()
}
})
export default router

122
src/stores/activities.js Normal file
View File

@@ -0,0 +1,122 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/lib/api'
export const useActivitiesStore = defineStore('activities', () => {
const activities = ref([])
const timeline = ref([])
const upcoming = ref([])
const overdue = ref([])
const stats = ref(null)
const loading = ref(false)
const error = ref(null)
const meta = ref({ page: 1, limit: 50, total: 0 })
async function fetchActivities(params = {}) {
loading.value = true
try {
const response = await api.get('/api/v1/activities', { params })
activities.value = response.data.data
meta.value = response.data.meta
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler'
} finally {
loading.value = false
}
}
async function fetchTimeline(entityType, entityId) {
loading.value = true
try {
const response = await api.get(`/api/v1/activities/timeline/${entityType}/${entityId}`)
timeline.value = response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler'
} finally {
loading.value = false
}
}
async function fetchUpcoming(days = 7, myOnly = false) {
try {
const response = await api.get('/api/v1/activities/upcoming', {
params: { days, myOnly }
})
upcoming.value = response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler'
}
}
async function fetchOverdue(myOnly = false) {
try {
const response = await api.get('/api/v1/activities/overdue', {
params: { myOnly }
})
overdue.value = response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler'
}
}
async function fetchStats() {
try {
const response = await api.get('/api/v1/activities/stats')
stats.value = response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler'
}
}
async function createActivity(data) {
try {
const response = await api.post('/api/v1/activities', data)
activities.value.unshift(response.data.data)
return response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler'
throw e
}
}
async function completeActivity(id, outcome = null) {
try {
const response = await api.post(`/api/v1/activities/${id}/complete`, { outcome })
const idx = activities.value.findIndex(a => a.id === id)
if (idx !== -1) activities.value[idx] = response.data.data
return response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler'
throw e
}
}
async function deleteActivity(id) {
try {
await api.delete(`/api/v1/activities/${id}`)
activities.value = activities.value.filter(a => a.id !== id)
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler'
throw e
}
}
return {
activities,
timeline,
upcoming,
overdue,
stats,
loading,
error,
meta,
fetchActivities,
fetchTimeline,
fetchUpcoming,
fetchOverdue,
fetchStats,
createActivity,
completeActivity,
deleteActivity
}
})

88
src/stores/auth.js Normal file
View File

@@ -0,0 +1,88 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/lib/api'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const token = ref(localStorage.getItem('pulse_token'))
const loading = ref(false)
const error = ref(null)
const isAuthenticated = computed(() => !!token.value && !!user.value)
async function login(email, password) {
loading.value = true
error.value = null
try {
const response = await api.post('/api/v1/auth/login', { email, password })
token.value = response.data.token
user.value = response.data.user
localStorage.setItem('pulse_token', token.value)
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
return true
} catch (e) {
error.value = e.response?.data?.error?.message || 'Login fehlgeschlagen'
return false
} finally {
loading.value = false
}
}
async function register(data) {
loading.value = true
error.value = null
try {
const response = await api.post('/api/v1/auth/register', data)
token.value = response.data.token
user.value = response.data.user
localStorage.setItem('pulse_token', token.value)
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
return true
} catch (e) {
error.value = e.response?.data?.error?.message || 'Registrierung fehlgeschlagen'
return false
} finally {
loading.value = false
}
}
async function fetchUser() {
if (!token.value) return false
try {
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
const response = await api.get('/api/v1/auth/me')
user.value = response.data.data
return true
} catch (e) {
logout()
return false
}
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem('pulse_token')
delete api.defaults.headers.common['Authorization']
}
// Initialize on load
if (token.value) {
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
}
return {
user,
token,
loading,
error,
isAuthenticated,
login,
register,
fetchUser,
logout
}
})

73
src/stores/companies.js Normal file
View File

@@ -0,0 +1,73 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/lib/api'
export const useCompaniesStore = defineStore('companies', () => {
const companies = ref([])
const currentCompany = ref(null)
const loading = ref(false)
const error = ref(null)
const meta = ref({ page: 1, limit: 25, total: 0 })
async function fetchCompanies(params = {}) {
loading.value = true
error.value = null
try {
const response = await api.get('/api/v1/companies', { params })
companies.value = response.data.data
meta.value = response.data.meta
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Laden'
} finally {
loading.value = false
}
}
async function fetchCompany(id) {
loading.value = true
try {
const response = await api.get(`/api/v1/companies/${id}`)
currentCompany.value = response.data.data
return currentCompany.value
} catch (e) {
error.value = e.response?.data?.error?.message || 'Firma nicht gefunden'
return null
} finally {
loading.value = false
}
}
async function createCompany(data) {
try {
const response = await api.post('/api/v1/companies', data)
companies.value.unshift(response.data.data)
return response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Erstellen'
throw e
}
}
async function deleteCompany(id) {
try {
await api.delete(`/api/v1/companies/${id}`)
companies.value = companies.value.filter(c => c.id !== id)
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Löschen'
throw e
}
}
return {
companies,
currentCompany,
loading,
error,
meta,
fetchCompanies,
fetchCompany,
createCompany,
deleteCompany
}
})

100
src/stores/contacts.js Normal file
View File

@@ -0,0 +1,100 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/lib/api'
export const useContactsStore = defineStore('contacts', () => {
const contacts = ref([])
const currentContact = ref(null)
const loading = ref(false)
const error = ref(null)
const meta = ref({ page: 1, limit: 25, total: 0 })
async function fetchContacts(params = {}) {
loading.value = true
error.value = null
try {
const response = await api.get('/api/v1/contacts', { params })
contacts.value = response.data.data
meta.value = response.data.meta
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Laden'
} finally {
loading.value = false
}
}
async function fetchContact(id) {
loading.value = true
error.value = null
try {
const response = await api.get(`/api/v1/contacts/${id}`)
currentContact.value = response.data.data
return currentContact.value
} catch (e) {
error.value = e.response?.data?.error?.message || 'Kontakt nicht gefunden'
return null
} finally {
loading.value = false
}
}
async function createContact(data) {
loading.value = true
error.value = null
try {
const response = await api.post('/api/v1/contacts', data)
contacts.value.unshift(response.data.data)
return response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Erstellen'
throw e
} finally {
loading.value = false
}
}
async function updateContact(id, data) {
loading.value = true
error.value = null
try {
const response = await api.put(`/api/v1/contacts/${id}`, data)
const index = contacts.value.findIndex(c => c.id === id)
if (index !== -1) contacts.value[index] = response.data.data
if (currentContact.value?.id === id) currentContact.value = response.data.data
return response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Aktualisieren'
throw e
} finally {
loading.value = false
}
}
async function deleteContact(id) {
try {
await api.delete(`/api/v1/contacts/${id}`)
contacts.value = contacts.value.filter(c => c.id !== id)
return true
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Löschen'
throw e
}
}
return {
contacts,
currentContact,
loading,
error,
meta,
fetchContacts,
fetchContact,
createContact,
updateContact,
deleteContact
}
})

133
src/stores/deals.js Normal file
View File

@@ -0,0 +1,133 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/lib/api'
export const useDealsStore = defineStore('deals', () => {
const pipelines = ref([])
const currentPipeline = ref(null)
const kanbanData = ref({ stages: [], deals: {} })
const deals = ref([])
const currentDeal = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchPipelines() {
try {
const response = await api.get('/api/v1/pipelines')
pipelines.value = response.data.data
if (pipelines.value.length && !currentPipeline.value) {
currentPipeline.value = pipelines.value[0]
}
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Laden'
}
}
async function fetchKanban(pipelineId) {
loading.value = true
error.value = null
try {
const response = await api.get(`/api/v1/pipelines/${pipelineId}/kanban`)
kanbanData.value = response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Laden'
} finally {
loading.value = false
}
}
async function fetchDeal(id) {
loading.value = true
try {
const response = await api.get(`/api/v1/deals/${id}`)
currentDeal.value = response.data.data
return currentDeal.value
} catch (e) {
error.value = e.response?.data?.error?.message || 'Deal nicht gefunden'
return null
} finally {
loading.value = false
}
}
async function createDeal(data) {
try {
const response = await api.post('/api/v1/deals', data)
// Refresh kanban if we have one
if (currentPipeline.value) {
await fetchKanban(currentPipeline.value.id)
}
return response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Erstellen'
throw e
}
}
async function moveDeal(dealId, stageId, position = null) {
try {
await api.post(`/api/v1/deals/${dealId}/move`, { stageId, position })
// Update locally
if (kanbanData.value.stages.length) {
// Find and move deal between stages
for (const stage of kanbanData.value.stages) {
const idx = (kanbanData.value.deals[stage.id] || []).findIndex(d => d.id === dealId)
if (idx !== -1) {
const [deal] = kanbanData.value.deals[stage.id].splice(idx, 1)
deal.stageId = stageId
kanbanData.value.deals[stageId] = kanbanData.value.deals[stageId] || []
if (position !== null) {
kanbanData.value.deals[stageId].splice(position, 0, deal)
} else {
kanbanData.value.deals[stageId].push(deal)
}
break
}
}
}
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Verschieben'
throw e
}
}
async function markWon(dealId, amount) {
try {
const response = await api.post(`/api/v1/deals/${dealId}/won`, { closedAmount: amount })
if (currentPipeline.value) await fetchKanban(currentPipeline.value.id)
return response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler'
throw e
}
}
async function markLost(dealId, reason) {
try {
const response = await api.post(`/api/v1/deals/${dealId}/lost`, { lostReason: reason })
if (currentPipeline.value) await fetchKanban(currentPipeline.value.id)
return response.data.data
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler'
throw e
}
}
return {
pipelines,
currentPipeline,
kanbanData,
deals,
currentDeal,
loading,
error,
fetchPipelines,
fetchKanban,
fetchDeal,
createDeal,
moveDeal,
markWon,
markLost
}
})

144
src/style.css Normal file
View File

@@ -0,0 +1,144 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-primary: 59 130 246;
}
* {
scrollbar-width: thin;
scrollbar-color: #475569 transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: #475569;
border-radius: 3px;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-pulse-dark disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply btn bg-pulse-card text-pulse-text border border-pulse-border hover:bg-pulse-border focus:ring-pulse-border;
}
.btn-ghost {
@apply btn text-pulse-muted hover:text-pulse-text hover:bg-pulse-card;
}
.btn-danger {
@apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
}
.btn-sm {
@apply px-3 py-1.5 text-sm;
}
.btn-icon {
@apply p-2;
}
.card {
@apply bg-pulse-card rounded-xl border border-pulse-border;
}
.input {
@apply w-full px-4 py-2.5 bg-pulse-dark border border-pulse-border rounded-lg text-pulse-text placeholder-pulse-muted focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all;
}
.label {
@apply block text-sm font-medium text-pulse-muted mb-1.5;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-blue {
@apply badge bg-blue-500/20 text-blue-400;
}
.badge-green {
@apply badge bg-green-500/20 text-green-400;
}
.badge-yellow {
@apply badge bg-yellow-500/20 text-yellow-400;
}
.badge-red {
@apply badge bg-red-500/20 text-red-400;
}
.badge-gray {
@apply badge bg-gray-500/20 text-gray-400;
}
.stat-card {
@apply card p-6;
}
.stat-value {
@apply text-3xl font-bold text-white;
}
.stat-label {
@apply text-sm text-pulse-muted mt-1;
}
.table-container {
@apply overflow-x-auto;
}
.table {
@apply w-full text-sm text-left;
}
.table th {
@apply px-4 py-3 text-xs font-semibold uppercase tracking-wider text-pulse-muted bg-pulse-dark/50 border-b border-pulse-border;
}
.table td {
@apply px-4 py-3 border-b border-pulse-border;
}
.table tr:hover td {
@apply bg-pulse-dark/30;
}
/* Kanban specific */
.kanban-column {
@apply flex-shrink-0 w-80 bg-pulse-dark/50 rounded-xl;
}
.kanban-card {
@apply card p-4 mb-3 cursor-pointer hover:border-primary-500 transition-colors;
}
/* Sidebar */
.sidebar-link {
@apply flex items-center gap-3 px-4 py-2.5 rounded-lg text-pulse-muted hover:text-pulse-text hover:bg-pulse-card transition-all;
}
.sidebar-link.active {
@apply bg-primary-600/20 text-primary-400;
}
}

View File

@@ -0,0 +1,219 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useActivitiesStore } from '@/stores/activities'
const activities = useActivitiesStore()
const showNewModal = ref(false)
const filter = ref('all') // all, upcoming, overdue
const newActivity = ref({
type: 'task',
subject: '',
dueDate: ''
})
onMounted(() => {
activities.fetchActivities()
activities.fetchStats()
})
async function createActivity() {
if (!newActivity.value.subject) return
try {
await activities.createActivity({
...newActivity.value,
dueDate: newActivity.value.dueDate || null
})
showNewModal.value = false
newActivity.value = { type: 'task', subject: '', dueDate: '' }
} catch (e) {
console.error('Error:', e)
}
}
async function completeActivity(id) {
await activities.completeActivity(id)
}
function formatDate(date) {
if (!date) return '-'
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const typeIcons = {
note: '📝',
call: '📞',
email: '✉️',
meeting: '🤝',
task: '✅'
}
const typeLabels = {
note: 'Notiz',
call: 'Anruf',
email: 'E-Mail',
meeting: 'Meeting',
task: 'Aufgabe'
}
</script>
<template>
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Aktivitäten</h1>
<p class="text-pulse-muted">Ihre Aufgaben und Aktivitäten</p>
</div>
<button @click="showNewModal = true" class="btn-primary">
<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>
Neue Aktivität
</button>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="card p-4">
<p class="text-2xl font-bold text-white">{{ activities.stats?.totalToday || 0 }}</p>
<p class="text-sm text-pulse-muted">Heute fällig</p>
</div>
<div class="card p-4">
<p class="text-2xl font-bold text-green-400">{{ activities.stats?.completedToday || 0 }}</p>
<p class="text-sm text-pulse-muted">Heute erledigt</p>
</div>
<div class="card p-4">
<p class="text-2xl font-bold text-red-400">{{ activities.stats?.overdue || 0 }}</p>
<p class="text-sm text-pulse-muted">Überfällig</p>
</div>
<div class="card p-4">
<p class="text-2xl font-bold text-blue-400">{{ activities.stats?.upcoming || 0 }}</p>
<p class="text-sm text-pulse-muted">Diese Woche</p>
</div>
</div>
<!-- Filter Tabs -->
<div class="flex gap-2 mb-6">
<button
@click="filter = 'all'; activities.fetchActivities()"
:class="['btn-sm', filter === 'all' ? 'btn-primary' : 'btn-ghost']"
>
Alle
</button>
<button
@click="filter = 'upcoming'; activities.fetchActivities({ isCompleted: 'false' })"
:class="['btn-sm', filter === 'upcoming' ? 'btn-primary' : 'btn-ghost']"
>
Offen
</button>
<button
@click="filter = 'completed'; activities.fetchActivities({ isCompleted: 'true' })"
:class="['btn-sm', filter === 'completed' ? 'btn-primary' : 'btn-ghost']"
>
Erledigt
</button>
</div>
<!-- List -->
<div class="card divide-y divide-pulse-border">
<div
v-for="activity in activities.activities"
:key="activity.id"
class="p-4 flex items-center gap-4 hover:bg-pulse-dark/30 transition-colors"
>
<!-- Checkbox -->
<button
@click.stop="completeActivity(activity.id)"
:class="[
'w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors',
activity.isCompleted
? 'bg-green-500 border-green-500 text-white'
: 'border-pulse-border hover:border-primary-500'
]"
>
<svg v-if="activity.isCompleted" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
</button>
<!-- Type Icon -->
<span class="text-xl">{{ typeIcons[activity.type] }}</span>
<!-- Content -->
<div class="flex-1 min-w-0">
<p :class="['font-medium', activity.isCompleted ? 'text-pulse-muted line-through' : 'text-white']">
{{ activity.subject }}
</p>
<div class="flex items-center gap-2 text-sm text-pulse-muted">
<span>{{ typeLabels[activity.type] }}</span>
<span v-if="activity.contact">· {{ activity.contact.name }}</span>
<span v-if="activity.company">· {{ activity.company.name }}</span>
</div>
</div>
<!-- Due Date -->
<span
v-if="activity.dueDate"
:class="[
'text-sm',
new Date(activity.dueDate) < new Date() && !activity.isCompleted
? 'text-red-400'
: 'text-pulse-muted'
]"
>
{{ formatDate(activity.dueDate) }}
</span>
</div>
<!-- Empty State -->
<div v-if="!activities.loading && !activities.activities.length" class="p-12 text-center">
<p class="text-pulse-muted">Keine Aktivitäten gefunden</p>
</div>
</div>
<!-- New Activity Modal -->
<Teleport to="body">
<div v-if="showNewModal" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" @click="showNewModal = false"></div>
<div class="relative card w-full max-w-md mx-4">
<div class="px-6 py-4 border-b border-pulse-border">
<h2 class="text-lg font-semibold text-white">Neue Aktivität</h2>
</div>
<form @submit.prevent="createActivity" class="p-6 space-y-4">
<div>
<label class="label">Typ</label>
<select v-model="newActivity.type" class="input">
<option value="task"> Aufgabe</option>
<option value="call">📞 Anruf</option>
<option value="email"> E-Mail</option>
<option value="meeting">🤝 Meeting</option>
<option value="note">📝 Notiz</option>
</select>
</div>
<div>
<label class="label">Betreff *</label>
<input v-model="newActivity.subject" type="text" class="input" required />
</div>
<div>
<label class="label">Fällig am</label>
<input v-model="newActivity.dueDate" type="datetime-local" class="input" />
</div>
<div class="flex gap-3 pt-2">
<button type="button" @click="showNewModal = false" class="btn-secondary flex-1">
Abbrechen
</button>
<button type="submit" class="btn-primary flex-1">
Erstellen
</button>
</div>
</form>
</div>
</div>
</Teleport>
</div>
</template>

169
src/views/CompaniesView.vue Normal file
View File

@@ -0,0 +1,169 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useDebounceFn } from '@vueuse/core'
import api from '@/lib/api'
const router = useRouter()
const companies = ref([])
const loading = ref(true)
const meta = ref({ total: 0, page: 1 })
const search = ref('')
const showNewModal = ref(false)
const newCompany = ref({
name: '',
website: '',
industry: '',
size: ''
})
async function fetchCompanies(params = {}) {
loading.value = true
try {
const response = await api.get('/api/v1/companies', { params })
companies.value = response.data.data
meta.value = response.data.meta
} catch (e) {
console.error('Error:', e)
} finally {
loading.value = false
}
}
onMounted(() => fetchCompanies())
const debouncedSearch = useDebounceFn(() => {
fetchCompanies({ search: search.value })
}, 300)
watch(search, debouncedSearch)
async function createCompany() {
if (!newCompany.value.name) return
try {
const response = await api.post('/api/v1/companies', newCompany.value)
showNewModal.value = false
newCompany.value = { name: '', website: '', industry: '', size: '' }
router.push(`/companies/${response.data.data.id}`)
} catch (e) {
console.error('Error:', e)
}
}
</script>
<template>
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Firmen</h1>
<p class="text-pulse-muted">{{ meta.total }} Firmen</p>
</div>
<button @click="showNewModal = true" class="btn-primary">
<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>
Neue Firma
</button>
</div>
<!-- Search -->
<div class="mb-6">
<div class="relative 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"
placeholder="Suchen..."
/>
</div>
</div>
<!-- Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="company in companies"
:key="company.id"
class="card p-5 cursor-pointer hover:border-primary-500 transition-colors"
@click="router.push(`/companies/${company.id}`)"
>
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-purple-600/20 flex items-center justify-center text-purple-400 font-bold text-lg">
{{ company.name?.[0]?.toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-white truncate">{{ company.name }}</h3>
<p v-if="company.industry" class="text-sm text-pulse-muted">{{ company.industry }}</p>
<div class="flex items-center gap-2 mt-2">
<span v-if="company.size" class="badge-gray">{{ company.size }}</span>
<span v-if="company.contactCount" class="text-xs text-pulse-muted">
{{ company.contactCount }} Kontakte
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-if="!loading && !companies.length" class="card p-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-pulse-muted opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<p class="text-pulse-muted">Noch keine Firmen vorhanden</p>
<button @click="showNewModal = true" class="btn-primary mt-4">
Erste Firma anlegen
</button>
</div>
<!-- New Company Modal -->
<Teleport to="body">
<div v-if="showNewModal" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" @click="showNewModal = false"></div>
<div class="relative card w-full max-w-lg mx-4">
<div class="px-6 py-4 border-b border-pulse-border">
<h2 class="text-lg font-semibold text-white">Neue Firma</h2>
</div>
<form @submit.prevent="createCompany" class="p-6 space-y-4">
<div>
<label class="label">Firmenname *</label>
<input v-model="newCompany.name" type="text" class="input" required />
</div>
<div>
<label class="label">Website</label>
<input v-model="newCompany.website" type="url" class="input" placeholder="https://..." />
</div>
<div>
<label class="label">Branche</label>
<input v-model="newCompany.industry" type="text" class="input" placeholder="z.B. IT, Handel" />
</div>
<div>
<label class="label">Größe</label>
<select v-model="newCompany.size" class="input">
<option value="">-- Auswählen --</option>
<option value="1-10">1-10 Mitarbeiter</option>
<option value="11-50">11-50 Mitarbeiter</option>
<option value="51-200">51-200 Mitarbeiter</option>
<option value="201-1000">201-1000 Mitarbeiter</option>
<option value="1000+">1000+ Mitarbeiter</option>
</select>
</div>
<div class="flex gap-3 pt-2">
<button type="button" @click="showNewModal = false" class="btn-secondary flex-1">
Abbrechen
</button>
<button type="submit" class="btn-primary flex-1">
Erstellen
</button>
</div>
</form>
</div>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,117 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '@/lib/api'
const route = useRoute()
const router = useRouter()
const company = ref(null)
const contacts = ref([])
const loading = ref(true)
onMounted(async () => {
try {
const [companyRes, contactsRes] = await Promise.all([
api.get(`/api/v1/companies/${route.params.id}`),
api.get('/api/v1/contacts', { params: { companyId: route.params.id } })
])
company.value = companyRes.data.data
contacts.value = contactsRes.data.data || []
} catch (e) {
console.error('Error:', e)
} finally {
loading.value = false
}
})
async function deleteCompany() {
if (!confirm('Firma wirklich löschen?')) return
await api.delete(`/api/v1/companies/${route.params.id}`)
router.push('/companies')
}
</script>
<template>
<div v-if="company" class="p-6 max-w-4xl mx-auto">
<!-- Header -->
<div class="flex items-start justify-between mb-6">
<div class="flex items-center gap-4">
<button @click="router.push('/companies')" class="btn-ghost btn-icon">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="w-16 h-16 rounded-xl bg-purple-600/20 flex items-center justify-center text-purple-400 text-2xl font-bold">
{{ company.name?.[0]?.toUpperCase() }}
</div>
<div>
<h1 class="text-2xl font-bold text-white">{{ company.name }}</h1>
<p v-if="company.industry" class="text-pulse-muted">{{ company.industry }}</p>
</div>
</div>
<button @click="deleteCompany" class="btn-danger">Löschen</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Info -->
<div class="card p-6">
<h2 class="font-semibold text-white mb-4">Firmendetails</h2>
<dl class="space-y-3 text-sm">
<div>
<dt class="text-pulse-muted">Website</dt>
<dd>
<a v-if="company.website" :href="company.website" target="_blank" class="text-primary-400 hover:underline">
{{ company.website }}
</a>
<span v-else class="text-white">-</span>
</dd>
</div>
<div>
<dt class="text-pulse-muted">Größe</dt>
<dd class="text-white">{{ company.size || '-' }}</dd>
</div>
<div>
<dt class="text-pulse-muted">Telefon</dt>
<dd class="text-white">{{ company.phone || '-' }}</dd>
</div>
</dl>
</div>
<!-- Contacts -->
<div class="lg:col-span-2 card">
<div class="px-6 py-4 border-b border-pulse-border flex items-center justify-between">
<h2 class="font-semibold text-white">Kontakte ({{ contacts.length }})</h2>
</div>
<div class="p-4">
<div v-if="!contacts.length" class="text-center py-8 text-pulse-muted">
Keine Kontakte
</div>
<div v-else class="space-y-3">
<div
v-for="contact in contacts"
:key="contact.id"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-pulse-dark/30 cursor-pointer"
@click="router.push(`/contacts/${contact.id}`)"
>
<div class="w-10 h-10 rounded-full bg-primary-600 flex items-center justify-center text-white font-medium">
{{ contact.firstName?.[0] }}{{ contact.lastName?.[0] }}
</div>
<div class="flex-1">
<p class="text-white">{{ contact.firstName }} {{ contact.lastName }}</p>
<p class="text-xs text-pulse-muted">{{ contact.position || contact.email }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="loading" class="flex items-center justify-center h-64">
<svg class="animate-spin h-8 w-8 text-primary-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
</template>

View File

@@ -0,0 +1,199 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useContactsStore } from '@/stores/contacts'
import { useActivitiesStore } from '@/stores/activities'
const route = useRoute()
const router = useRouter()
const contacts = useContactsStore()
const activities = useActivitiesStore()
const editing = ref(false)
const editForm = ref({})
onMounted(async () => {
await contacts.fetchContact(route.params.id)
if (contacts.currentContact) {
await activities.fetchTimeline('contact', route.params.id)
}
})
function startEdit() {
editForm.value = { ...contacts.currentContact }
editing.value = true
}
async function saveEdit() {
try {
await contacts.updateContact(route.params.id, editForm.value)
editing.value = false
} catch (e) {
console.error('Error:', e)
}
}
async function deleteContact() {
if (!confirm('Kontakt wirklich löschen?')) return
await contacts.deleteContact(route.params.id)
router.push('/contacts')
}
function formatDate(date) {
if (!date) return '-'
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const c = contacts.currentContact
</script>
<template>
<div v-if="contacts.currentContact" class="p-6 max-w-4xl mx-auto">
<!-- Header -->
<div class="flex items-start justify-between mb-6">
<div class="flex items-center gap-4">
<button @click="router.push('/contacts')" class="btn-ghost btn-icon">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="w-16 h-16 rounded-full bg-primary-600 flex items-center justify-center text-white text-2xl font-bold">
{{ contacts.currentContact.firstName?.[0] }}{{ contacts.currentContact.lastName?.[0] }}
</div>
<div>
<h1 class="text-2xl font-bold text-white">
{{ contacts.currentContact.firstName }} {{ contacts.currentContact.lastName }}
</h1>
<p v-if="contacts.currentContact.position" class="text-pulse-muted">
{{ contacts.currentContact.position }}
<span v-if="contacts.currentContact.company"> @ {{ contacts.currentContact.company.name }}</span>
</p>
</div>
</div>
<div class="flex gap-2">
<button @click="startEdit" class="btn-secondary">Bearbeiten</button>
<button @click="deleteContact" class="btn-danger">Löschen</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Info Card -->
<div class="card p-6">
<h2 class="font-semibold text-white mb-4">Kontaktdaten</h2>
<dl class="space-y-3 text-sm">
<div>
<dt class="text-pulse-muted">E-Mail</dt>
<dd class="text-white">
<a v-if="contacts.currentContact.email" :href="`mailto:${contacts.currentContact.email}`" class="text-primary-400 hover:underline">
{{ contacts.currentContact.email }}
</a>
<span v-else>-</span>
</dd>
</div>
<div>
<dt class="text-pulse-muted">Telefon</dt>
<dd class="text-white">{{ contacts.currentContact.phone || '-' }}</dd>
</div>
<div>
<dt class="text-pulse-muted">Mobil</dt>
<dd class="text-white">{{ contacts.currentContact.mobile || '-' }}</dd>
</div>
<div>
<dt class="text-pulse-muted">Adresse</dt>
<dd class="text-white">
<template v-if="contacts.currentContact.street">
{{ contacts.currentContact.street }}<br>
{{ contacts.currentContact.zip }} {{ contacts.currentContact.city }}<br>
{{ contacts.currentContact.country }}
</template>
<span v-else>-</span>
</dd>
</div>
</dl>
</div>
<!-- Timeline -->
<div class="lg:col-span-2 card">
<div class="px-6 py-4 border-b border-pulse-border">
<h2 class="font-semibold text-white">Timeline</h2>
</div>
<div class="p-4 max-h-96 overflow-y-auto">
<div v-if="!activities.timeline.length" class="text-center py-8 text-pulse-muted">
Keine Aktivitäten
</div>
<div v-else class="space-y-4">
<div v-for="activity in activities.timeline" :key="activity.id" class="flex gap-3">
<div class="w-8 h-8 rounded-full bg-pulse-dark flex items-center justify-center text-sm">
{{ activity.type === 'call' ? '📞' : activity.type === 'email' ? '✉️' : activity.type === 'meeting' ? '🤝' : '📝' }}
</div>
<div class="flex-1">
<p class="text-white">{{ activity.subject }}</p>
<p class="text-xs text-pulse-muted">{{ formatDate(activity.createdAt) }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<Teleport to="body">
<div v-if="editing" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" @click="editing = false"></div>
<div class="relative card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
<div class="px-6 py-4 border-b border-pulse-border">
<h2 class="text-lg font-semibold text-white">Kontakt bearbeiten</h2>
</div>
<form @submit.prevent="saveEdit" class="p-6 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">Vorname</label>
<input v-model="editForm.firstName" type="text" class="input" />
</div>
<div>
<label class="label">Nachname</label>
<input v-model="editForm.lastName" type="text" class="input" />
</div>
</div>
<div>
<label class="label">E-Mail</label>
<input v-model="editForm.email" type="email" class="input" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">Telefon</label>
<input v-model="editForm.phone" type="tel" class="input" />
</div>
<div>
<label class="label">Mobil</label>
<input v-model="editForm.mobile" type="tel" class="input" />
</div>
</div>
<div>
<label class="label">Position</label>
<input v-model="editForm.position" type="text" class="input" />
</div>
<div class="flex gap-3 pt-2">
<button type="button" @click="editing = false" class="btn-secondary flex-1">Abbrechen</button>
<button type="submit" class="btn-primary flex-1">Speichern</button>
</div>
</form>
</div>
</div>
</Teleport>
</div>
<!-- Loading -->
<div v-else class="flex items-center justify-center h-64">
<svg class="animate-spin h-8 w-8 text-primary-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
</template>

177
src/views/ContactsView.vue Normal file
View File

@@ -0,0 +1,177 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useContactsStore } from '@/stores/contacts'
import { useDebounceFn } from '@vueuse/core'
const router = useRouter()
const contacts = useContactsStore()
const search = ref('')
const showNewModal = ref(false)
const newContact = ref({
firstName: '',
lastName: '',
email: '',
phone: '',
position: ''
})
onMounted(() => {
contacts.fetchContacts()
})
const debouncedSearch = useDebounceFn(() => {
contacts.fetchContacts({ search: search.value })
}, 300)
watch(search, debouncedSearch)
async function createContact() {
if (!newContact.value.firstName || !newContact.value.lastName) return
try {
const contact = await contacts.createContact(newContact.value)
showNewModal.value = false
newContact.value = { firstName: '', lastName: '', email: '', phone: '', position: '' }
router.push(`/contacts/${contact.id}`)
} catch (e) {
console.error('Error:', e)
}
}
function getInitials(contact) {
return (contact.firstName?.[0] || '') + (contact.lastName?.[0] || '')
}
</script>
<template>
<div class="p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Kontakte</h1>
<p class="text-pulse-muted">{{ contacts.meta.total }} Kontakte</p>
</div>
<button @click="showNewModal = true" class="btn-primary">
<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 Kontakt
</button>
</div>
<!-- Search -->
<div class="mb-6">
<div class="relative 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"
placeholder="Suchen..."
/>
</div>
</div>
<!-- Table -->
<div class="card">
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Telefon</th>
<th>Firma</th>
<th>Position</th>
</tr>
</thead>
<tbody>
<tr
v-for="contact in contacts.contacts"
:key="contact.id"
class="cursor-pointer"
@click="router.push(`/contacts/${contact.id}`)"
>
<td>
<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 text-sm font-medium">
{{ getInitials(contact) }}
</div>
<div>
<p class="font-medium text-white">{{ contact.firstName }} {{ contact.lastName }}</p>
</div>
</div>
</td>
<td class="text-pulse-muted">{{ contact.email || '-' }}</td>
<td class="text-pulse-muted">{{ contact.phone || '-' }}</td>
<td>
<span v-if="contact.company" class="text-pulse-text">{{ contact.company.name }}</span>
<span v-else class="text-pulse-muted">-</span>
</td>
<td class="text-pulse-muted">{{ contact.position || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty State -->
<div v-if="!contacts.loading && !contacts.contacts.length" class="p-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-pulse-muted opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p class="text-pulse-muted">Noch keine Kontakte vorhanden</p>
<button @click="showNewModal = true" class="btn-primary mt-4">
Ersten Kontakt anlegen
</button>
</div>
</div>
<!-- New Contact Modal -->
<Teleport to="body">
<div v-if="showNewModal" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" @click="showNewModal = false"></div>
<div class="relative card w-full max-w-lg mx-4">
<div class="px-6 py-4 border-b border-pulse-border">
<h2 class="text-lg font-semibold text-white">Neuer Kontakt</h2>
</div>
<form @submit.prevent="createContact" class="p-6 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">Vorname *</label>
<input v-model="newContact.firstName" type="text" class="input" required />
</div>
<div>
<label class="label">Nachname *</label>
<input v-model="newContact.lastName" type="text" class="input" required />
</div>
</div>
<div>
<label class="label">E-Mail</label>
<input v-model="newContact.email" type="email" class="input" placeholder="name@firma.de" />
</div>
<div>
<label class="label">Telefon</label>
<input v-model="newContact.phone" type="tel" class="input" placeholder="+49 123 456789" />
</div>
<div>
<label class="label">Position</label>
<input v-model="newContact.position" type="text" class="input" placeholder="z.B. Geschäftsführer" />
</div>
<div class="flex gap-3 pt-2">
<button type="button" @click="showNewModal = false" class="btn-secondary flex-1">
Abbrechen
</button>
<button type="submit" class="btn-primary flex-1">
Erstellen
</button>
</div>
</form>
</div>
</div>
</Teleport>
</div>
</template>

204
src/views/DashboardView.vue Normal file
View File

@@ -0,0 +1,204 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useActivitiesStore } from '@/stores/activities'
import { useDealsStore } from '@/stores/deals'
import api from '@/lib/api'
const auth = useAuthStore()
const activities = useActivitiesStore()
const deals = useDealsStore()
const stats = ref({
contacts: 0,
companies: 0,
openDeals: 0,
pipelineValue: 0
})
const loading = ref(true)
onMounted(async () => {
try {
// Fetch dashboard data
const [contactsRes, companiesRes, dealsRes] = await Promise.all([
api.get('/api/v1/contacts', { params: { limit: 1 } }),
api.get('/api/v1/companies', { params: { limit: 1 } }),
api.get('/api/v1/deals/stats')
])
stats.value.contacts = contactsRes.data.meta?.total || 0
stats.value.companies = companiesRes.data.meta?.total || 0
stats.value.openDeals = dealsRes.data.data?.openDeals || 0
stats.value.pipelineValue = dealsRes.data.data?.pipelineValue || 0
// Fetch activities
await Promise.all([
activities.fetchUpcoming(7),
activities.fetchOverdue(),
activities.fetchStats()
])
} catch (e) {
console.error('Dashboard load error:', e)
} finally {
loading.value = false
}
})
function formatCurrency(value) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value || 0)
}
function formatDate(date) {
if (!date) return '-'
return new Date(date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
</script>
<template>
<div class="p-6">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">
Guten Tag, {{ auth.user?.firstName }}! 👋
</h1>
<p class="text-pulse-muted mt-1">Hier ist Ihr Überblick für heute.</p>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="stat-card">
<div class="flex items-center gap-4">
<div class="p-3 rounded-xl bg-blue-500/20">
<svg class="w-6 h-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div>
<p class="stat-value">{{ stats.contacts }}</p>
<p class="stat-label">Kontakte</p>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center gap-4">
<div class="p-3 rounded-xl bg-purple-500/20">
<svg class="w-6 h-6 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<p class="stat-value">{{ stats.companies }}</p>
<p class="stat-label">Firmen</p>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center gap-4">
<div class="p-3 rounded-xl bg-green-500/20">
<svg class="w-6 h-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="stat-value">{{ stats.openDeals }}</p>
<p class="stat-label">Offene Deals</p>
</div>
</div>
</div>
<div class="stat-card">
<div class="flex items-center gap-4">
<div class="p-3 rounded-xl bg-yellow-500/20">
<svg class="w-6 h-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p class="stat-value">{{ formatCurrency(stats.pipelineValue) }}</p>
<p class="stat-label">Pipeline-Wert</p>
</div>
</div>
</div>
</div>
<!-- Two Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Overdue Activities -->
<div class="card">
<div class="px-6 py-4 border-b border-pulse-border flex items-center justify-between">
<h2 class="font-semibold text-white">Überfällige Aufgaben</h2>
<span v-if="activities.overdue.length" class="badge-red">
{{ activities.overdue.length }}
</span>
</div>
<div class="p-4">
<div v-if="activities.overdue.length === 0" class="text-center py-8 text-pulse-muted">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>Alles erledigt! 🎉</p>
</div>
<div v-else class="space-y-3">
<div
v-for="activity in activities.overdue.slice(0, 5)"
:key="activity.id"
class="flex items-center gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20"
>
<div class="flex-shrink-0">
<span class="badge-red">{{ activity.type }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-white truncate">{{ activity.subject }}</p>
<p class="text-xs text-pulse-muted">
{{ activity.contact?.name || activity.company?.name || '-' }}
</p>
</div>
<span class="text-xs text-red-400">{{ formatDate(activity.dueDate) }}</span>
</div>
</div>
</div>
</div>
<!-- Upcoming Activities -->
<div class="card">
<div class="px-6 py-4 border-b border-pulse-border flex items-center justify-between">
<h2 class="font-semibold text-white">Anstehend (7 Tage)</h2>
<RouterLink to="/activities" class="text-sm text-primary-400 hover:text-primary-300">
Alle anzeigen
</RouterLink>
</div>
<div class="p-4">
<div v-if="activities.upcoming.length === 0" class="text-center py-8 text-pulse-muted">
<p>Keine anstehenden Aufgaben</p>
</div>
<div v-else class="space-y-3">
<div
v-for="activity in activities.upcoming.slice(0, 5)"
:key="activity.id"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-pulse-dark/30 transition-colors"
>
<div class="flex-shrink-0">
<span :class="[
'badge',
activity.type === 'call' ? 'badge-blue' :
activity.type === 'meeting' ? 'badge-green' :
activity.type === 'task' ? 'badge-yellow' : 'badge-gray'
]">{{ activity.type }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-white truncate">{{ activity.subject }}</p>
<p class="text-xs text-pulse-muted">
{{ activity.contact?.name || activity.company?.name || '-' }}
</p>
</div>
<span class="text-xs text-pulse-muted">{{ formatDate(activity.dueDate) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup>
import { onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDealsStore } from '@/stores/deals'
const route = useRoute()
const router = useRouter()
const deals = useDealsStore()
onMounted(() => {
deals.fetchDeal(route.params.id)
})
function formatCurrency(value) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value || 0)
}
</script>
<template>
<div v-if="deals.currentDeal" class="p-6 max-w-4xl mx-auto">
<div class="flex items-center gap-4 mb-6">
<button @click="router.push('/pipeline')" class="btn-ghost btn-icon">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
<h1 class="text-2xl font-bold text-white">{{ deals.currentDeal.title }}</h1>
<p class="text-pulse-muted">{{ formatCurrency(deals.currentDeal.value) }}</p>
</div>
</div>
<div class="card p-6">
<h2 class="font-semibold text-white mb-4">Deal Details</h2>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-pulse-muted">Status</dt>
<dd class="text-white">{{ deals.currentDeal.status }}</dd>
</div>
<div>
<dt class="text-pulse-muted">Wahrscheinlichkeit</dt>
<dd class="text-white">{{ deals.currentDeal.probability }}%</dd>
</div>
<div>
<dt class="text-pulse-muted">Erwarteter Abschluss</dt>
<dd class="text-white">{{ deals.currentDeal.expectedCloseDate || '-' }}</dd>
</div>
<div>
<dt class="text-pulse-muted">Kontakt</dt>
<dd class="text-white">{{ deals.currentDeal.contact?.name || '-' }}</dd>
</div>
</dl>
</div>
</div>
<div v-else class="flex items-center justify-center h-64">
<svg class="animate-spin h-8 w-8 text-primary-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
</template>

145
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,145 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const isLogin = ref(true)
const form = ref({
email: '',
password: '',
firstName: '',
lastName: '',
orgName: ''
})
async function handleSubmit() {
if (isLogin.value) {
const success = await auth.login(form.value.email, form.value.password)
if (success) router.push('/')
} else {
const success = await auth.register({
email: form.value.email,
password: form.value.password,
firstName: form.value.firstName,
lastName: form.value.lastName,
orgName: form.value.orgName
})
if (success) router.push('/')
}
}
</script>
<template>
<div class="min-h-full flex items-center justify-center px-4">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary-500 to-primary-700 mb-4">
<svg class="w-10 h-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h1 class="text-3xl font-bold text-white">Pulse CRM</h1>
<p class="text-pulse-muted mt-2">Ihre Kundenbeziehungen im Griff</p>
</div>
<!-- Form Card -->
<div class="card p-8">
<h2 class="text-xl font-semibold text-white mb-6">
{{ isLogin ? 'Anmelden' : 'Registrieren' }}
</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<template v-if="!isLogin">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">Vorname</label>
<input
v-model="form.firstName"
type="text"
class="input"
required
/>
</div>
<div>
<label class="label">Nachname</label>
<input
v-model="form.lastName"
type="text"
class="input"
required
/>
</div>
</div>
<div>
<label class="label">Firmenname</label>
<input
v-model="form.orgName"
type="text"
class="input"
placeholder="Ihre Firma"
required
/>
</div>
</template>
<div>
<label class="label">E-Mail</label>
<input
v-model="form.email"
type="email"
class="input"
placeholder="name@firma.de"
required
/>
</div>
<div>
<label class="label">Passwort</label>
<input
v-model="form.password"
type="password"
class="input"
placeholder="••••••••"
required
/>
</div>
<p v-if="auth.error" class="text-red-400 text-sm">
{{ auth.error }}
</p>
<button
type="submit"
class="btn-primary w-full"
:disabled="auth.loading"
>
<span v-if="auth.loading">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
{{ isLogin ? 'Anmelden' : 'Konto erstellen' }}
</button>
</form>
<div class="mt-6 text-center">
<button
@click="isLogin = !isLogin"
class="text-primary-400 hover:text-primary-300 text-sm"
>
{{ isLogin ? 'Noch kein Konto? Jetzt registrieren' : 'Bereits registriert? Anmelden' }}
</button>
</div>
</div>
<p class="text-center text-pulse-muted text-sm mt-6">
© 2026 Kronos Soulution · DSGVO-konform
</p>
</div>
</div>
</template>

213
src/views/PipelineView.vue Normal file
View File

@@ -0,0 +1,213 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useDealsStore } from '@/stores/deals'
const deals = useDealsStore()
const showNewDealModal = ref(false)
const draggedDeal = ref(null)
const newDeal = ref({
title: '',
value: '',
contactId: '',
companyId: '',
expectedCloseDate: ''
})
onMounted(async () => {
await deals.fetchPipelines()
if (deals.currentPipeline) {
await deals.fetchKanban(deals.currentPipeline.id)
}
})
function formatCurrency(value) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0
}).format(value || 0)
}
function getStageColor(index, total) {
const colors = ['bg-gray-500', 'bg-blue-500', 'bg-yellow-500', 'bg-orange-500', 'bg-green-500']
return colors[Math.min(index, colors.length - 1)]
}
function handleDragStart(e, deal) {
draggedDeal.value = deal
e.dataTransfer.effectAllowed = 'move'
}
function handleDragOver(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
async function handleDrop(e, stageId) {
e.preventDefault()
if (draggedDeal.value && draggedDeal.value.stageId !== stageId) {
await deals.moveDeal(draggedDeal.value.id, stageId)
}
draggedDeal.value = null
}
async function createDeal() {
if (!newDeal.value.title) return
try {
await deals.createDeal({
title: newDeal.value.title,
value: parseFloat(newDeal.value.value) || 0,
stageId: deals.kanbanData.stages[0]?.id,
expectedCloseDate: newDeal.value.expectedCloseDate || null
})
showNewDealModal.value = false
newDeal.value = { title: '', value: '', contactId: '', companyId: '', expectedCloseDate: '' }
} catch (e) {
console.error('Error creating deal:', e)
}
}
function calculateStageValue(stageId) {
const stageDeals = deals.kanbanData.deals[stageId] || []
return stageDeals.reduce((sum, d) => sum + (d.value || 0), 0)
}
</script>
<template>
<div class="h-full flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-pulse-border bg-pulse-card">
<div>
<h1 class="text-xl font-bold text-white">Sales Pipeline</h1>
<p class="text-sm text-pulse-muted">{{ deals.currentPipeline?.name || 'Loading...' }}</p>
</div>
<button @click="showNewDealModal = true" class="btn-primary">
<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 Deal
</button>
</div>
<!-- Kanban Board -->
<div class="flex-1 overflow-x-auto p-6">
<div v-if="deals.loading" class="flex items-center justify-center h-64">
<svg class="animate-spin h-8 w-8 text-primary-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<div v-else class="flex gap-4 h-full min-w-max">
<div
v-for="(stage, index) in deals.kanbanData.stages"
:key="stage.id"
class="kanban-column flex flex-col"
@dragover="handleDragOver"
@drop="handleDrop($event, stage.id)"
>
<!-- Stage Header -->
<div class="p-4 border-b border-pulse-border">
<div class="flex items-center gap-2 mb-2">
<div :class="['w-3 h-3 rounded-full', getStageColor(index, deals.kanbanData.stages.length)]"></div>
<h3 class="font-semibold text-white">{{ stage.name }}</h3>
<span class="text-xs text-pulse-muted bg-pulse-card px-2 py-0.5 rounded-full">
{{ (deals.kanbanData.deals[stage.id] || []).length }}
</span>
</div>
<p class="text-sm text-pulse-muted">
{{ formatCurrency(calculateStageValue(stage.id)) }}
</p>
</div>
<!-- Deals List -->
<div class="flex-1 overflow-y-auto p-3 space-y-3">
<div
v-for="deal in deals.kanbanData.deals[stage.id] || []"
:key="deal.id"
class="kanban-card"
draggable="true"
@dragstart="handleDragStart($event, deal)"
>
<div class="flex items-start justify-between mb-2">
<h4 class="font-medium text-white text-sm">{{ deal.title }}</h4>
<span class="text-xs font-semibold text-green-400">
{{ formatCurrency(deal.value) }}
</span>
</div>
<div v-if="deal.company" class="text-xs text-pulse-muted mb-2">
🏢 {{ deal.company.name }}
</div>
<div v-if="deal.contact" class="text-xs text-pulse-muted mb-2">
👤 {{ deal.contact.name }}
</div>
<div class="flex items-center justify-between text-xs">
<span v-if="deal.expectedCloseDate" class="text-pulse-muted">
📅 {{ new Date(deal.expectedCloseDate).toLocaleDateString('de-DE') }}
</span>
<span v-if="deal.probability" :class="[
'px-2 py-0.5 rounded-full',
deal.probability >= 70 ? 'bg-green-500/20 text-green-400' :
deal.probability >= 40 ? 'bg-yellow-500/20 text-yellow-400' :
'bg-red-500/20 text-red-400'
]">
{{ deal.probability }}%
</span>
</div>
</div>
<!-- Empty State -->
<div
v-if="!(deals.kanbanData.deals[stage.id] || []).length"
class="flex flex-col items-center justify-center py-8 text-pulse-muted"
>
<svg class="w-8 h-8 mb-2 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p class="text-sm">Keine Deals</p>
</div>
</div>
</div>
</div>
</div>
<!-- New Deal Modal -->
<Teleport to="body">
<div v-if="showNewDealModal" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60" @click="showNewDealModal = false"></div>
<div class="relative card w-full max-w-md mx-4">
<div class="px-6 py-4 border-b border-pulse-border">
<h2 class="text-lg font-semibold text-white">Neuer Deal</h2>
</div>
<form @submit.prevent="createDeal" class="p-6 space-y-4">
<div>
<label class="label">Titel *</label>
<input v-model="newDeal.title" type="text" class="input" placeholder="z.B. Website Redesign" required />
</div>
<div>
<label class="label">Wert ()</label>
<input v-model="newDeal.value" type="number" class="input" placeholder="10000" />
</div>
<div>
<label class="label">Erwarteter Abschluss</label>
<input v-model="newDeal.expectedCloseDate" type="date" class="input" />
</div>
<div class="flex gap-3 pt-2">
<button type="button" @click="showNewDealModal = false" class="btn-secondary flex-1">
Abbrechen
</button>
<button type="submit" class="btn-primary flex-1">
Erstellen
</button>
</div>
</form>
</div>
</div>
</Teleport>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup>
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
</script>
<template>
<div class="p-6 max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-6">Einstellungen</h1>
<!-- Profile -->
<div class="card p-6 mb-6">
<h2 class="font-semibold text-white mb-4">Profil</h2>
<div class="flex items-center gap-4 mb-6">
<div class="w-16 h-16 rounded-full bg-primary-600 flex items-center justify-center text-white text-2xl font-bold">
{{ auth.user?.firstName?.[0] }}
</div>
<div>
<p class="text-lg font-medium text-white">{{ auth.user?.firstName }} {{ auth.user?.lastName }}</p>
<p class="text-pulse-muted">{{ auth.user?.email }}</p>
</div>
</div>
<p class="text-sm text-pulse-muted">
Rolle: <span class="text-white capitalize">{{ auth.user?.role }}</span>
</p>
</div>
<!-- DSGVO -->
<div class="card p-6">
<h2 class="font-semibold text-white mb-4">Datenschutz (DSGVO)</h2>
<p class="text-pulse-muted text-sm mb-4">
Ihre Daten werden DSGVO-konform auf EU-Servern gespeichert.
</p>
<div class="space-y-2">
<button class="btn-secondary btn-sm">Daten exportieren</button>
<button class="btn-danger btn-sm ml-2">Konto löschen</button>
</div>
</div>
</div>
</template>

36
tailwind.config.js Normal file
View File

@@ -0,0 +1,36 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
pulse: {
dark: '#0f172a',
card: '#1e293b',
border: '#334155',
text: '#e2e8f0',
muted: '#94a3b8',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}

22
vite.config.js Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 3001,
proxy: {
'/api': {
target: 'https://api.crm.kronos-soulution.de',
changeOrigin: true,
secure: true
}
}
}
})