From 01d542b6b6d75d9a1701c2e7d4ea0ad8f882ec6b Mon Sep 17 00:00:00 2001 From: FluxKit Date: Wed, 11 Feb 2026 11:24:04 +0000 Subject: [PATCH] feat: Pulse CRM Frontend v1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.production | 1 + Dockerfile | 37 ++++++ index.html | 17 +++ package.json | 25 ++++ postcss.config.js | 6 + public/pulse-icon.svg | 10 ++ src/App.vue | 21 +++ src/layouts/AppLayout.vue | 108 ++++++++++++++++ src/lib/api.js | 23 ++++ src/main.js | 12 ++ src/router/index.js | 86 +++++++++++++ src/stores/activities.js | 122 ++++++++++++++++++ src/stores/auth.js | 88 +++++++++++++ src/stores/companies.js | 73 +++++++++++ src/stores/contacts.js | 100 +++++++++++++++ src/stores/deals.js | 133 +++++++++++++++++++ src/style.css | 144 +++++++++++++++++++++ src/views/ActivitiesView.vue | 219 ++++++++++++++++++++++++++++++++ src/views/CompaniesView.vue | 169 ++++++++++++++++++++++++ src/views/CompanyDetailView.vue | 117 +++++++++++++++++ src/views/ContactDetailView.vue | 199 +++++++++++++++++++++++++++++ src/views/ContactsView.vue | 177 ++++++++++++++++++++++++++ src/views/DashboardView.vue | 204 +++++++++++++++++++++++++++++ src/views/DealDetailView.vue | 62 +++++++++ src/views/LoginView.vue | 145 +++++++++++++++++++++ src/views/PipelineView.vue | 213 +++++++++++++++++++++++++++++++ src/views/SettingsView.vue | 40 ++++++ tailwind.config.js | 36 ++++++ vite.config.js | 22 ++++ 29 files changed, 2609 insertions(+) create mode 100644 .env.production create mode 100644 Dockerfile create mode 100644 index.html create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/pulse-icon.svg create mode 100644 src/App.vue create mode 100644 src/layouts/AppLayout.vue create mode 100644 src/lib/api.js create mode 100644 src/main.js create mode 100644 src/router/index.js create mode 100644 src/stores/activities.js create mode 100644 src/stores/auth.js create mode 100644 src/stores/companies.js create mode 100644 src/stores/contacts.js create mode 100644 src/stores/deals.js create mode 100644 src/style.css create mode 100644 src/views/ActivitiesView.vue create mode 100644 src/views/CompaniesView.vue create mode 100644 src/views/CompanyDetailView.vue create mode 100644 src/views/ContactDetailView.vue create mode 100644 src/views/ContactsView.vue create mode 100644 src/views/DashboardView.vue create mode 100644 src/views/DealDetailView.vue create mode 100644 src/views/LoginView.vue create mode 100644 src/views/PipelineView.vue create mode 100644 src/views/SettingsView.vue create mode 100644 tailwind.config.js create mode 100644 vite.config.js diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..292a14c --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +VITE_API_URL= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4242b95 --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/index.html b/index.html new file mode 100644 index 0000000..9784ea8 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Pulse CRM + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..c3c65b4 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/pulse-icon.svg b/public/pulse-icon.svg new file mode 100644 index 0000000..d0d991c --- /dev/null +++ b/public/pulse-icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..fcf167d --- /dev/null +++ b/src/App.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/layouts/AppLayout.vue b/src/layouts/AppLayout.vue new file mode 100644 index 0000000..24d4a64 --- /dev/null +++ b/src/layouts/AppLayout.vue @@ -0,0 +1,108 @@ + + + diff --git a/src/lib/api.js b/src/lib/api.js new file mode 100644 index 0000000..4ec0383 --- /dev/null +++ b/src/lib/api.js @@ -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 diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..e33b0cf --- /dev/null +++ b/src/main.js @@ -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') diff --git a/src/router/index.js b/src/router/index.js new file mode 100644 index 0000000..9e87c25 --- /dev/null +++ b/src/router/index.js @@ -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 diff --git a/src/stores/activities.js b/src/stores/activities.js new file mode 100644 index 0000000..b396bdf --- /dev/null +++ b/src/stores/activities.js @@ -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 + } +}) diff --git a/src/stores/auth.js b/src/stores/auth.js new file mode 100644 index 0000000..c3c5601 --- /dev/null +++ b/src/stores/auth.js @@ -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 + } +}) diff --git a/src/stores/companies.js b/src/stores/companies.js new file mode 100644 index 0000000..e01799d --- /dev/null +++ b/src/stores/companies.js @@ -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 + } +}) diff --git a/src/stores/contacts.js b/src/stores/contacts.js new file mode 100644 index 0000000..13dcbbc --- /dev/null +++ b/src/stores/contacts.js @@ -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 + } +}) diff --git a/src/stores/deals.js b/src/stores/deals.js new file mode 100644 index 0000000..71250ed --- /dev/null +++ b/src/stores/deals.js @@ -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 + } +}) diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..0b265b6 --- /dev/null +++ b/src/style.css @@ -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; + } +} diff --git a/src/views/ActivitiesView.vue b/src/views/ActivitiesView.vue new file mode 100644 index 0000000..bd52c3a --- /dev/null +++ b/src/views/ActivitiesView.vue @@ -0,0 +1,219 @@ + + + diff --git a/src/views/CompaniesView.vue b/src/views/CompaniesView.vue new file mode 100644 index 0000000..00a7e9c --- /dev/null +++ b/src/views/CompaniesView.vue @@ -0,0 +1,169 @@ + + + diff --git a/src/views/CompanyDetailView.vue b/src/views/CompanyDetailView.vue new file mode 100644 index 0000000..49dc5c1 --- /dev/null +++ b/src/views/CompanyDetailView.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/views/ContactDetailView.vue b/src/views/ContactDetailView.vue new file mode 100644 index 0000000..f9123e3 --- /dev/null +++ b/src/views/ContactDetailView.vue @@ -0,0 +1,199 @@ + + + diff --git a/src/views/ContactsView.vue b/src/views/ContactsView.vue new file mode 100644 index 0000000..dea4902 --- /dev/null +++ b/src/views/ContactsView.vue @@ -0,0 +1,177 @@ + + + diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue new file mode 100644 index 0000000..16309c1 --- /dev/null +++ b/src/views/DashboardView.vue @@ -0,0 +1,204 @@ + + + diff --git a/src/views/DealDetailView.vue b/src/views/DealDetailView.vue new file mode 100644 index 0000000..27738a5 --- /dev/null +++ b/src/views/DealDetailView.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 0000000..51deaad --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,145 @@ + + + diff --git a/src/views/PipelineView.vue b/src/views/PipelineView.vue new file mode 100644 index 0000000..3f5b0ee --- /dev/null +++ b/src/views/PipelineView.vue @@ -0,0 +1,213 @@ + + + diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue new file mode 100644 index 0000000..04b4138 --- /dev/null +++ b/src/views/SettingsView.vue @@ -0,0 +1,40 @@ + + + diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..813b1f3 --- /dev/null +++ b/tailwind.config.js @@ -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: [], +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..00df7ca --- /dev/null +++ b/vite.config.js @@ -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 + } + } + } +})