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:
1
.env.production
Normal file
1
.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=
|
||||
37
Dockerfile
Normal file
37
Dockerfile
Normal 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
17
index.html
Normal 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
25
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
10
public/pulse-icon.svg
Normal file
10
public/pulse-icon.svg
Normal 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
21
src/App.vue
Normal 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
108
src/layouts/AppLayout.vue
Normal 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
23
src/lib/api.js
Normal 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
12
src/main.js
Normal 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
86
src/router/index.js
Normal 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
122
src/stores/activities.js
Normal 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
88
src/stores/auth.js
Normal 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
73
src/stores/companies.js
Normal 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
100
src/stores/contacts.js
Normal 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
133
src/stores/deals.js
Normal 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
144
src/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
219
src/views/ActivitiesView.vue
Normal file
219
src/views/ActivitiesView.vue
Normal 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
169
src/views/CompaniesView.vue
Normal 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>
|
||||
117
src/views/CompanyDetailView.vue
Normal file
117
src/views/CompanyDetailView.vue
Normal 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>
|
||||
199
src/views/ContactDetailView.vue
Normal file
199
src/views/ContactDetailView.vue
Normal 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
177
src/views/ContactsView.vue
Normal 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
204
src/views/DashboardView.vue
Normal 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>
|
||||
62
src/views/DealDetailView.vue
Normal file
62
src/views/DealDetailView.vue
Normal 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
145
src/views/LoginView.vue
Normal 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
213
src/views/PipelineView.vue
Normal 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>
|
||||
40
src/views/SettingsView.vue
Normal file
40
src/views/SettingsView.vue
Normal 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
36
tailwind.config.js
Normal 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
22
vite.config.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user