Compare commits

..

8 Commits

Author SHA1 Message Date
FluxKit
c5e41c2577 Fix: Input text color in team member modal - use pulse-dark/pulse-text 2026-02-25 16:03:49 +00:00
FluxKit
3d2c4e2f26 feat: Add LeadsView with role-based filtering
- Users see only their own leads
- Manager/Admin/CEO see all leads
- Filter by owner dropdown for managers
- Lead source field
- Mobile-responsive cards
2026-02-25 13:30:59 +00:00
FluxKit
7b0fd718b0 feat: Redesign navigation menu like Close.com
- Inbox, Chancen (Pipeline), Leads, Kontakte, Firmen, Aktivitäten
- Konversationen, Workflows, Berichte (coming soon)
- Team & Einstellungen at bottom
- Expandable submenus for Leads and Workflows
- User profile at top of sidebar
- German labels
2026-02-25 13:25:19 +00:00
FluxKit
954a1f01d6 feat: Add Leads submenu with Kontakte and Firmen as children 2026-02-25 13:19:41 +00:00
FluxKit
2b059e1bfb fix: Correct api import path 2026-02-25 13:05:53 +00:00
FluxKit
5e1b49c4de fix: Replace @apply with plain CSS for Tailwind compatibility 2026-02-25 13:05:13 +00:00
FluxKit
8ae8abd7a7 feat: Add Inbox view with task/appointment/email management
- Add InboxView.vue with full CRUD UI
- Add inbox to navigation (first item)
- Support filtering by status/type
- Show stats (due today, overdue, etc.)
- Quick status updates with checkbox
- Priority indicators and overdue warnings
2026-02-25 13:02:04 +00:00
FluxKit
5f63514fe0 feat: add team management UI
- New UsersView for managing organization members
- Role badges with colors (CEO, CRM Admin, Manager, User)
- Create/invite users, edit, deactivate, delete, reset password
- Navigation only visible for owner/admin roles
2026-02-25 10:03:46 +00:00
27 changed files with 4372 additions and 66 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

File diff suppressed because one or more lines are too long

1
dist/assets/AppLayout-DOV1d8ZL.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/CompaniesView-DDKYWHXV.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{i as w,s as u,c as a,a as e,f,t as s,e as y,F as b,k as g,m as C,r as p,h as j,o as i}from"./index-DWNalbut.js";const N={key:0,class:"p-6 max-w-4xl mx-auto"},B={class:"flex items-start justify-between mb-6"},$={class:"flex items-center gap-4"},F={class:"w-16 h-16 rounded-xl bg-purple-600/20 flex items-center justify-center text-purple-400 text-2xl font-bold"},R={class:"text-2xl font-bold text-white"},V={key:0,class:"text-pulse-muted"},K={class:"grid grid-cols-1 lg:grid-cols-3 gap-6"},M={class:"card p-6"},z={class:"space-y-3 text-sm"},D=["href"],E={key:1,class:"text-white"},L={class:"text-white"},G={class:"text-white"},I={class:"lg:col-span-2 card"},P={class:"px-6 py-4 border-b border-pulse-border flex items-center justify-between"},S={class:"font-semibold text-white"},T={class:"p-4"},U={key:0,class:"text-center py-8 text-pulse-muted"},W={key:1,class:"space-y-3"},q=["onClick"],A={class:"w-10 h-10 rounded-full bg-primary-600 flex items-center justify-center text-white font-medium"},H={class:"flex-1"},J={class:"text-white"},O={class:"text-xs text-pulse-muted"},Q={key:1,class:"flex items-center justify-center h-64"},ee={__name:"CompanyDetailView",setup(X){const d=C(),c=j(),o=p(null),n=p([]),m=p(!0);w(async()=>{try{const[r,t]=await Promise.all([u.get(`/api/v1/companies/${d.params.id}`),u.get("/api/v1/contacts",{params:{companyId:d.params.id}})]);o.value=r.data.data,n.value=t.data.data||[]}catch(r){console.error("Error:",r)}finally{m.value=!1}});async function k(){confirm("Firma wirklich löschen?")&&(await u.delete(`/api/v1/companies/${d.params.id}`),c.push("/companies"))}return(r,t)=>{var h,v;return o.value?(i(),a("div",N,[e("div",B,[e("div",$,[e("button",{onClick:t[0]||(t[0]=l=>f(c).push("/companies")),class:"btn-ghost btn-icon"},[...t[1]||(t[1]=[e("svg",{class:"w-5 h-5",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 19l-7-7 7-7"})],-1)])]),e("div",F,s((v=(h=o.value.name)==null?void 0:h[0])==null?void 0:v.toUpperCase()),1),e("div",null,[e("h1",R,s(o.value.name),1),o.value.industry?(i(),a("p",V,s(o.value.industry),1)):y("",!0)])]),e("button",{onClick:k,class:"btn-danger"},"Löschen")]),e("div",K,[e("div",M,[t[5]||(t[5]=e("h2",{class:"font-semibold text-white mb-4"},"Firmendetails",-1)),e("dl",z,[e("div",null,[t[2]||(t[2]=e("dt",{class:"text-pulse-muted"},"Website",-1)),e("dd",null,[o.value.website?(i(),a("a",{key:0,href:o.value.website,target:"_blank",class:"text-primary-400 hover:underline"},s(o.value.website),9,D)):(i(),a("span",E,"-"))])]),e("div",null,[t[3]||(t[3]=e("dt",{class:"text-pulse-muted"},"Größe",-1)),e("dd",L,s(o.value.size||"-"),1)]),e("div",null,[t[4]||(t[4]=e("dt",{class:"text-pulse-muted"},"Telefon",-1)),e("dd",G,s(o.value.phone||"-"),1)])])]),e("div",I,[e("div",P,[e("h2",S,"Kontakte ("+s(n.value.length)+")",1)]),e("div",T,[n.value.length?(i(),a("div",W,[(i(!0),a(b,null,g(n.value,l=>{var _,x;return i(),a("div",{key:l.id,class:"flex items-center gap-3 p-3 rounded-lg hover:bg-pulse-dark/30 cursor-pointer",onClick:Y=>f(c).push(`/contacts/${l.id}`)},[e("div",A,s((_=l.firstName)==null?void 0:_[0])+s((x=l.lastName)==null?void 0:x[0]),1),e("div",H,[e("p",J,s(l.firstName)+" "+s(l.lastName),1),e("p",O,s(l.position||l.email),1)])],8,q)}),128))])):(i(),a("div",U," Keine Kontakte "))])])])])):m.value?(i(),a("div",Q,[...t[6]||(t[6]=[e("svg",{class:"animate-spin h-8 w-8 text-primary-500",fill:"none",viewBox:"0 0 24 24"},[e("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"}),e("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"})],-1)])])):y("",!0)}}};export{ee as default};

File diff suppressed because one or more lines are too long

1
dist/assets/ContactsView-BMha6-Fv.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/DashboardView-B_vOqYxU.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{i as p,f as l,c as i,a as t,t as o,m,h as x,o as a}from"./index-DWNalbut.js";import{u as h}from"./deals-Bh3SQGc9.js";const f={key:0,class:"p-6 max-w-4xl mx-auto"},w={class:"flex items-center gap-4 mb-6"},D={class:"text-2xl font-bold text-white"},v={class:"text-pulse-muted"},_={class:"card p-6"},k={class:"grid grid-cols-2 gap-4 text-sm"},y={class:"text-white"},b={class:"text-white"},g={class:"text-white"},C={class:"text-white"},B={key:1,class:"flex items-center justify-center h-64"},V={__name:"DealDetailView",setup(E){const d=m(),u=x(),s=h();p(()=>{s.fetchDeal(d.params.id)});function c(r){return new Intl.NumberFormat("de-DE",{style:"currency",currency:"EUR"}).format(r||0)}return(r,e)=>{var n;return l(s).currentDeal?(a(),i("div",f,[t("div",w,[t("button",{onClick:e[0]||(e[0]=M=>l(u).push("/pipeline")),class:"btn-ghost btn-icon"},[...e[1]||(e[1]=[t("svg",{class:"w-5 h-5",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 19l-7-7 7-7"})],-1)])]),t("div",null,[t("h1",D,o(l(s).currentDeal.title),1),t("p",v,o(c(l(s).currentDeal.value)),1)])]),t("div",_,[e[6]||(e[6]=t("h2",{class:"font-semibold text-white mb-4"},"Deal Details",-1)),t("dl",k,[t("div",null,[e[2]||(e[2]=t("dt",{class:"text-pulse-muted"},"Status",-1)),t("dd",y,o(l(s).currentDeal.status),1)]),t("div",null,[e[3]||(e[3]=t("dt",{class:"text-pulse-muted"},"Wahrscheinlichkeit",-1)),t("dd",b,o(l(s).currentDeal.probability)+"%",1)]),t("div",null,[e[4]||(e[4]=t("dt",{class:"text-pulse-muted"},"Erwarteter Abschluss",-1)),t("dd",g,o(l(s).currentDeal.expectedCloseDate||"-"),1)]),t("div",null,[e[5]||(e[5]=t("dt",{class:"text-pulse-muted"},"Kontakt",-1)),t("dd",C,o(((n=l(s).currentDeal.contact)==null?void 0:n.name)||"-"),1)])])])])):(a(),i("div",B,[...e[7]||(e[7]=[t("svg",{class:"animate-spin h-8 w-8 text-primary-500",fill:"none",viewBox:"0 0 24 24"},[t("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"}),t("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"})],-1)])]))}}};export{V as default};

1
dist/assets/LoginView-PMaCJFIu.js vendored Normal file
View File

@@ -0,0 +1 @@
import{u as w,c as n,a as t,b,t as u,w as g,F as y,d as i,v as o,e as p,f as d,g as N,r as f,h,o as m}from"./index-DWNalbut.js";const k={class:"min-h-full flex items-center justify-center px-4"},V={class:"w-full max-w-md"},C={class:"card p-8"},S={class:"text-xl font-semibold text-white mb-6"},B={class:"grid grid-cols-2 gap-4"},M={key:1,class:"text-red-400 text-sm"},q=["disabled"],z={key:0},A={class:"mt-6 text-center"},K={__name:"LoginView",setup(U){const v=h(),r=w(),a=f(!0),s=f({email:"",password:"",firstName:"",lastName:"",orgName:""});async function x(){a.value?await r.login(s.value.email,s.value.password)&&v.push("/"):await r.register({email:s.value.email,password:s.value.password,firstName:s.value.firstName,lastName:s.value.lastName,orgName:s.value.orgName})&&v.push("/")}return(c,e)=>(m(),n("div",k,[t("div",V,[e[12]||(e[12]=b('<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"></path></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>',1)),t("div",C,[t("h2",S,u(a.value?"Anmelden":"Registrieren"),1),t("form",{onSubmit:g(x,["prevent"]),class:"space-y-4"},[a.value?p("",!0):(m(),n(y,{key:0},[t("div",B,[t("div",null,[e[6]||(e[6]=t("label",{class:"label"},"Vorname",-1)),i(t("input",{"onUpdate:modelValue":e[0]||(e[0]=l=>s.value.firstName=l),type:"text",class:"input",required:""},null,512),[[o,s.value.firstName]])]),t("div",null,[e[7]||(e[7]=t("label",{class:"label"},"Nachname",-1)),i(t("input",{"onUpdate:modelValue":e[1]||(e[1]=l=>s.value.lastName=l),type:"text",class:"input",required:""},null,512),[[o,s.value.lastName]])])]),t("div",null,[e[8]||(e[8]=t("label",{class:"label"},"Firmenname",-1)),i(t("input",{"onUpdate:modelValue":e[2]||(e[2]=l=>s.value.orgName=l),type:"text",class:"input",placeholder:"Ihre Firma",required:""},null,512),[[o,s.value.orgName]])])],64)),t("div",null,[e[9]||(e[9]=t("label",{class:"label"},"E-Mail",-1)),i(t("input",{"onUpdate:modelValue":e[3]||(e[3]=l=>s.value.email=l),type:"email",class:"input",placeholder:"name@firma.de",required:""},null,512),[[o,s.value.email]])]),t("div",null,[e[10]||(e[10]=t("label",{class:"label"},"Passwort",-1)),i(t("input",{"onUpdate:modelValue":e[4]||(e[4]=l=>s.value.password=l),type:"password",class:"input",placeholder:"••••••••",required:""},null,512),[[o,s.value.password]])]),d(r).error?(m(),n("p",M,u(d(r).error),1)):p("",!0),t("button",{type:"submit",class:"btn-primary w-full",disabled:d(r).loading},[d(r).loading?(m(),n("span",z,[...e[11]||(e[11]=[t("svg",{class:"animate-spin -ml-1 mr-2 h-4 w-4 text-white inline",fill:"none",viewBox:"0 0 24 24"},[t("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"}),t("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"})],-1)])])):p("",!0),N(" "+u(a.value?"Anmelden":"Konto erstellen"),1)],8,q)],32),t("div",A,[t("button",{onClick:e[5]||(e[5]=l=>a.value=!a.value),class:"text-primary-400 hover:text-primary-300 text-sm"},u(a.value?"Noch kein Konto? Jetzt registrieren":"Bereits registriert? Anmelden"),1)])]),e[13]||(e[13]=t("p",{class:"text-center text-pulse-muted text-sm mt-6"}," © 2026 Kronos Soulution · DSGVO-konform ",-1))])]))}};export{K as default};

1
dist/assets/PipelineView-BkXeArbf.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/SettingsView-ByWddGMW.js vendored Normal file
View File

@@ -0,0 +1 @@
import{u as m,c as u,a as t,t as o,f as n,g as p,b as x,o as b}from"./index-DWNalbut.js";const f={class:"p-6 max-w-2xl mx-auto"},h={class:"card p-6 mb-6"},_={class:"flex items-center gap-4 mb-6"},v={class:"w-16 h-16 rounded-full bg-primary-600 flex items-center justify-center text-white text-2xl font-bold"},w={class:"text-lg font-medium text-white"},g={class:"text-pulse-muted"},S={class:"text-sm text-pulse-muted"},D={class:"text-white capitalize"},k={__name:"SettingsView",setup(N){const s=m();return(V,e)=>{var a,l,i,r,d,c;return b(),u("div",f,[e[2]||(e[2]=t("h1",{class:"text-2xl font-bold text-white mb-6"},"Einstellungen",-1)),t("div",h,[e[1]||(e[1]=t("h2",{class:"font-semibold text-white mb-4"},"Profil",-1)),t("div",_,[t("div",v,o((l=(a=n(s).user)==null?void 0:a.firstName)==null?void 0:l[0]),1),t("div",null,[t("p",w,o((i=n(s).user)==null?void 0:i.firstName)+" "+o((r=n(s).user)==null?void 0:r.lastName),1),t("p",g,o((d=n(s).user)==null?void 0:d.email),1)])]),t("p",S,[e[0]||(e[0]=p(" Rolle: ",-1)),t("span",D,o((c=n(s).user)==null?void 0:c.role),1)])]),e[3]||(e[3]=x('<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>',1))])}}};export{k as default};

1
dist/assets/activities-DN88j27F.js vendored Normal file
View File

@@ -0,0 +1 @@
import{z as I,r as n,s as o}from"./index-DWNalbut.js";const T=I("activities",()=>{const l=n([]),u=n([]),d=n([]),p=n([]),f=n(null),v=n(!1),c=n(null),h=n({page:1,limit:50,total:0});async function y(i={}){var s,t,a;v.value=!0;try{const e=await o.get("/api/v1/activities",{params:i});l.value=e.data.data,h.value=e.data.meta}catch(e){c.value=((a=(t=(s=e.response)==null?void 0:s.data)==null?void 0:t.error)==null?void 0:a.message)||"Fehler"}finally{v.value=!1}}async function g(i,s){var t,a,e;v.value=!0;try{const r=await o.get(`/api/v1/activities/timeline/${i}/${s}`);u.value=r.data.data}catch(r){c.value=((e=(a=(t=r.response)==null?void 0:t.data)==null?void 0:a.error)==null?void 0:e.message)||"Fehler"}finally{v.value=!1}}async function w(i=7,s=!1){var t,a,e;try{const r=await o.get("/api/v1/activities/upcoming",{params:{days:i,myOnly:s}});d.value=r.data.data}catch(r){c.value=((e=(a=(t=r.response)==null?void 0:t.data)==null?void 0:a.error)==null?void 0:e.message)||"Fehler"}}async function F(i=!1){var s,t,a;try{const e=await o.get("/api/v1/activities/overdue",{params:{myOnly:i}});p.value=e.data.data}catch(e){c.value=((a=(t=(s=e.response)==null?void 0:s.data)==null?void 0:t.error)==null?void 0:a.message)||"Fehler"}}async function A(){var i,s,t;try{const a=await o.get("/api/v1/activities/stats");f.value=a.data.data}catch(a){c.value=((t=(s=(i=a.response)==null?void 0:i.data)==null?void 0:s.error)==null?void 0:t.message)||"Fehler"}}async function $(i){var s,t,a;try{const e=await o.post("/api/v1/activities",i);return l.value.unshift(e.data.data),e.data.data}catch(e){throw c.value=((a=(t=(s=e.response)==null?void 0:s.data)==null?void 0:t.error)==null?void 0:a.message)||"Fehler",e}}async function x(i,s=null){var t,a,e;try{const r=await o.post(`/api/v1/activities/${i}/complete`,{outcome:s}),m=l.value.findIndex(z=>z.id===i);return m!==-1&&(l.value[m]=r.data.data),r.data.data}catch(r){throw c.value=((e=(a=(t=r.response)==null?void 0:t.data)==null?void 0:a.error)==null?void 0:e.message)||"Fehler",r}}async function S(i){var s,t,a;try{await o.delete(`/api/v1/activities/${i}`),l.value=l.value.filter(e=>e.id!==i)}catch(e){throw c.value=((a=(t=(s=e.response)==null?void 0:s.data)==null?void 0:t.error)==null?void 0:a.message)||"Fehler",e}}return{activities:l,timeline:u,upcoming:d,overdue:p,stats:f,loading:v,error:c,meta:h,fetchActivities:y,fetchTimeline:g,fetchUpcoming:w,fetchOverdue:F,fetchStats:A,createActivity:$,completeActivity:x,deleteActivity:S}});export{T as u};

1
dist/assets/contacts-B_I94NMP.js vendored Normal file
View File

@@ -0,0 +1 @@
import{z as b,r as i,s as v}from"./index-DWNalbut.js";const x=b("contacts",()=>{const u=i([]),o=i(null),l=i(!1),s=i(null),d=i({page:1,limit:25,total:0});async function h(r={}){var n,e,t;l.value=!0,s.value=null;try{const a=await v.get("/api/v1/contacts",{params:r});u.value=a.data.data,d.value=a.data.meta}catch(a){s.value=((t=(e=(n=a.response)==null?void 0:n.data)==null?void 0:e.error)==null?void 0:t.message)||"Fehler beim Laden"}finally{l.value=!1}}async function m(r){var n,e,t;l.value=!0,s.value=null;try{const a=await v.get(`/api/v1/contacts/${r}`);return o.value=a.data.data,o.value}catch(a){return s.value=((t=(e=(n=a.response)==null?void 0:n.data)==null?void 0:e.error)==null?void 0:t.message)||"Kontakt nicht gefunden",null}finally{l.value=!1}}async function y(r){var n,e,t;l.value=!0,s.value=null;try{const a=await v.post("/api/v1/contacts",r);return u.value.unshift(a.data.data),a.data.data}catch(a){throw s.value=((t=(e=(n=a.response)==null?void 0:n.data)==null?void 0:e.error)==null?void 0:t.message)||"Fehler beim Erstellen",a}finally{l.value=!1}}async function g(r,n){var e,t,a,f;l.value=!0,s.value=null;try{const c=await v.put(`/api/v1/contacts/${r}`,n),p=u.value.findIndex(C=>C.id===r);return p!==-1&&(u.value[p]=c.data.data),((e=o.value)==null?void 0:e.id)===r&&(o.value=c.data.data),c.data.data}catch(c){throw s.value=((f=(a=(t=c.response)==null?void 0:t.data)==null?void 0:a.error)==null?void 0:f.message)||"Fehler beim Aktualisieren",c}finally{l.value=!1}}async function w(r){var n,e,t;try{return await v.delete(`/api/v1/contacts/${r}`),u.value=u.value.filter(a=>a.id!==r),!0}catch(a){throw s.value=((t=(e=(n=a.response)==null?void 0:n.data)==null?void 0:e.error)==null?void 0:t.message)||"Fehler beim Löschen",a}}return{contacts:u,currentContact:o,loading:l,error:s,meta:d,fetchContacts:h,fetchContact:m,createContact:y,updateContact:g,deleteContact:w}});export{x as u};

1
dist/assets/deals-Bh3SQGc9.js vendored Normal file
View File

@@ -0,0 +1 @@
import{z as x,r as i,s as c}from"./index-DWNalbut.js";const P=x("deals",()=>{const f=i([]),o=i(null),r=i({stages:[],deals:{}}),w=i([]),m=i(null),d=i(!1),u=i(null);async function g(){var l,e,a;try{const s=await c.get("/api/v1/pipelines");f.value=s.data.data,f.value.length&&!o.value&&(o.value=f.value[0])}catch(s){u.value=((a=(e=(l=s.response)==null?void 0:l.data)==null?void 0:e.error)==null?void 0:a.message)||"Fehler beim Laden"}}async function h(l){var e,a,s;d.value=!0,u.value=null;try{const t=await c.get(`/api/v1/pipelines/${l}/kanban`);r.value=t.data.data}catch(t){u.value=((s=(a=(e=t.response)==null?void 0:e.data)==null?void 0:a.error)==null?void 0:s.message)||"Fehler beim Laden"}finally{d.value=!1}}async function b(l){var e,a,s;d.value=!0;try{const t=await c.get(`/api/v1/deals/${l}`);return m.value=t.data.data,m.value}catch(t){return u.value=((s=(a=(e=t.response)==null?void 0:e.data)==null?void 0:a.error)==null?void 0:s.message)||"Deal nicht gefunden",null}finally{d.value=!1}}async function D(l){var e,a,s;try{const t=await c.post("/api/v1/deals",l);return o.value&&await h(o.value.id),t.data.data}catch(t){throw u.value=((s=(a=(e=t.response)==null?void 0:e.data)==null?void 0:a.error)==null?void 0:s.message)||"Fehler beim Erstellen",t}}async function F(l,e,a=null){var s,t,n;try{if(await c.post(`/api/v1/deals/${l}/move`,{stageId:e,position:a}),r.value.stages.length)for(const v of r.value.stages){const y=(r.value.deals[v.id]||[]).findIndex(p=>p.id===l);if(y!==-1){const[p]=r.value.deals[v.id].splice(y,1);p.stageId=e,r.value.deals[e]=r.value.deals[e]||[],a!==null?r.value.deals[e].splice(a,0,p):r.value.deals[e].push(p);break}}}catch(v){throw u.value=((n=(t=(s=v.response)==null?void 0:s.data)==null?void 0:t.error)==null?void 0:n.message)||"Fehler beim Verschieben",v}}async function k(l,e){var a,s,t;try{const n=await c.post(`/api/v1/deals/${l}/won`,{closedAmount:e});return o.value&&await h(o.value.id),n.data.data}catch(n){throw u.value=((t=(s=(a=n.response)==null?void 0:a.data)==null?void 0:s.error)==null?void 0:t.message)||"Fehler",n}}async function $(l,e){var a,s,t;try{const n=await c.post(`/api/v1/deals/${l}/lost`,{lostReason:e});return o.value&&await h(o.value.id),n.data.data}catch(n){throw u.value=((t=(s=(a=n.response)==null?void 0:a.data)==null?void 0:s.error)==null?void 0:t.message)||"Fehler",n}}return{pipelines:f,currentPipeline:o,kanbanData:r,deals:w,currentDeal:m,loading:d,error:u,fetchPipelines:g,fetchKanban:h,fetchDeal:b,createDeal:D,moveDeal:F,markWon:k,markLost:$}});export{P as u};

35
dist/assets/index-DWNalbut.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-DeJ0q2c_.js vendored Normal file
View File

@@ -0,0 +1 @@
import{f as p}from"./index-DWNalbut.js";function f(t){return typeof t=="function"?t():p(t)}typeof WorkerGlobalScope<"u"&&globalThis instanceof WorkerGlobalScope;const s=()=>{};function h(t,n){function r(...e){return new Promise((i,o)=>{Promise.resolve(t(()=>n.apply(this,e),{fn:n,thisArg:this,args:e})).then(i).catch(o)})}return r}function T(t,n={}){let r,e,i=s;const o=u=>{clearTimeout(u),i(),i=s};return u=>{const a=f(t),l=f(n.maxWait);return r&&o(r),a<=0||l!==void 0&&l<=0?(e&&(o(e),e=null),Promise.resolve(u())):new Promise((c,m)=>{i=n.rejectOnCancel?m:c,l&&!e&&(e=setTimeout(()=>{r&&o(r),e=null,c(u())},l)),r=setTimeout(()=>{e&&o(e),e=null,c(u())},a)})}}function x(t,n=200,r={}){return h(T(n,r),t)}export{x as u};

1
dist/assets/index-Dvjxh_uJ.css vendored Normal file

File diff suppressed because one or more lines are too long

18
dist/index.html vendored Normal file
View File

@@ -0,0 +1,18 @@
<!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>
<script type="module" crossorigin src="/assets/index-DWNalbut.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dvjxh_uJ.css">
</head>
<body class="h-full bg-pulse-dark text-pulse-text antialiased">
<div id="app" class="h-full"></div>
</body>
</html>

10
dist/pulse-icon.svg vendored Normal file
View File

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

After

Width:  |  Height:  |  Size: 514 B

2670
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -9,17 +9,17 @@
"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"
"axios": "^1.6.0",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0"
"@vitejs/plugin-vue": "^5.2.4",
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"vite": "^5.4.21"
}
}

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
@@ -9,14 +9,48 @@ const auth = useAuthStore()
const sidebarOpen = ref(false)
const isMobile = ref(false)
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' },
// Expandable menu states
const leadsOpen = ref(true)
const workflowsOpen = ref(false)
// Main navigation items
const mainNavItems = [
{ name: 'Inbox', path: '/inbox', icon: 'inbox' },
{ name: 'Chancen', path: '/pipeline', icon: 'opportunities' },
{
name: 'Leads',
icon: 'leads',
expandable: 'leads',
children: [
{ name: 'Alle Leads', path: '/leads', icon: 'list' },
]
},
{ name: 'Kontakte', path: '/contacts', icon: 'contacts' },
{ name: 'Firmen', path: '/companies', icon: 'companies' },
{ name: 'Aktivitäten', path: '/activities', icon: 'activities' },
{ name: 'Konversationen', path: '/conversations', icon: 'conversations', badge: 'soon' },
{
name: 'Workflows',
icon: 'workflows',
expandable: 'workflows',
badge: 'soon',
children: [
{ name: 'Automatisierungen', path: '/workflows/automations', icon: 'automation' },
]
},
{ name: 'Berichte', path: '/reports', icon: 'reports', badge: 'soon' },
]
// Bottom navigation items
const bottomNavItems = computed(() => {
const items = []
if (auth.user?.role === 'owner' || auth.user?.role === 'admin') {
items.push({ name: 'Team', path: '/team', icon: 'team' })
}
items.push({ name: 'Einstellungen', path: '/settings', icon: 'settings' })
return items
})
function checkMobile() {
isMobile.value = window.innerWidth < 768
if (!isMobile.value) {
@@ -39,6 +73,17 @@ function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
function toggleExpand(key) {
if (key === 'leads') leadsOpen.value = !leadsOpen.value
if (key === 'workflows') workflowsOpen.value = !workflowsOpen.value
}
function isExpanded(key) {
if (key === 'leads') return leadsOpen.value
if (key === 'workflows') return workflowsOpen.value
return false
}
function closeSidebarOnMobile() {
if (isMobile.value) {
sidebarOpen.value = false
@@ -54,6 +99,11 @@ function isActive(path) {
if (path === '/') return route.path === '/'
return route.path.startsWith(path)
}
function isGroupActive(item) {
if (!item.children) return false
return item.children.some(child => route.path.startsWith(child.path))
}
</script>
<template>
@@ -97,15 +147,16 @@ function isActive(path) {
'w-64'
]"
>
<!-- Logo -->
<div class="flex items-center justify-between gap-3 px-6 py-5 border-b border-pulse-border">
<div class="flex items-center gap-3">
<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>
<!-- User Profile -->
<div class="flex items-center gap-3 px-4 py-4 border-b border-pulse-border">
<div class="w-10 h-10 rounded-full bg-primary-600 flex items-center justify-center text-white font-medium flex-shrink-0">
{{ auth.user?.firstName?.[0] || 'U' }}{{ auth.user?.lastName?.[0] || '' }}
</div>
<span class="text-xl font-bold text-white">Pulse</span>
<div 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">Pulse CRM</p>
</div>
<!-- Close button on mobile -->
<button
@@ -119,59 +170,132 @@ function isActive(path) {
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 py-4 px-3 space-y-1 overflow-y-auto">
<!-- Main Navigation -->
<nav class="flex-1 py-2 px-2 space-y-0.5 overflow-y-auto">
<template v-for="item in mainNavItems" :key="item.path || item.name">
<!-- Regular nav item -->
<RouterLink
v-for="item in navItems"
v-if="!item.children"
:to="item.badge === 'soon' ? '#' : item.path"
:class="[
'sidebar-link group',
isActive(item.path) && 'active',
item.badge === 'soon' && 'opacity-60 cursor-not-allowed'
]"
@click.prevent="item.badge === 'soon' ? null : (closeSidebarOnMobile(), $router.push(item.path))"
>
<!-- Icons -->
<svg v-if="item.icon === 'inbox'" class="w-5 h-5 flex-shrink-0" 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>
<svg v-if="item.icon === 'opportunities'" class="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
<svg v-if="item.icon === 'contacts'" class="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<svg v-if="item.icon === 'companies'" class="w-5 h-5 flex-shrink-0" 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 === 'activities'" class="w-5 h-5 flex-shrink-0" 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>
<svg v-if="item.icon === 'conversations'" class="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<svg v-if="item.icon === 'reports'" class="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span class="flex-1">{{ item.name }}</span>
<span v-if="item.badge === 'soon'" class="text-[10px] px-1.5 py-0.5 bg-pulse-border rounded text-pulse-muted">Bald</span>
</RouterLink>
<!-- Expandable menu -->
<div v-else class="space-y-0.5">
<button
@click="toggleExpand(item.expandable)"
:class="['sidebar-link w-full justify-between', isGroupActive(item) && 'active']"
>
<div class="flex items-center gap-3">
<svg v-if="item.icon === 'leads'" class="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<svg v-if="item.icon === 'workflows'" class="w-5 h-5 flex-shrink-0" 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>
<span>{{ item.name }}</span>
</div>
<div class="flex items-center gap-1">
<span v-if="item.badge === 'soon'" class="text-[10px] px-1.5 py-0.5 bg-pulse-border rounded text-pulse-muted">Bald</span>
<svg
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-180': isExpanded(item.expandable) }"
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
<!-- Submenu items -->
<div
v-show="isExpanded(item.expandable)"
class="pl-4 space-y-0.5"
>
<RouterLink
v-for="child in item.children"
:key="child.path"
:to="item.badge === 'soon' ? '#' : child.path"
:class="[
'sidebar-link text-sm',
isActive(child.path) && 'active',
item.badge === 'soon' && 'opacity-60 cursor-not-allowed'
]"
@click="closeSidebarOnMobile"
>
<svg v-if="child.icon === 'list'" class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<svg v-if="child.icon === 'automation'" class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>{{ child.name }}</span>
</RouterLink>
</div>
</div>
</template>
</nav>
<!-- Bottom Navigation -->
<div class="border-t border-pulse-border py-2 px-2 space-y-0.5">
<RouterLink
v-for="item in bottomNavItems"
:key="item.path"
:to="item.path"
:class="['sidebar-link', isActive(item.path) && 'active']"
@click="closeSidebarOnMobile"
>
<!-- Icons -->
<svg v-if="item.icon === 'chart-pie'" class="w-5 h-5 flex-shrink-0" 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 v-if="item.icon === 'team'" class="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<svg v-if="item.icon === 'users'" class="w-5 h-5 flex-shrink-0" 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 flex-shrink-0" 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 flex-shrink-0" 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 flex-shrink-0" 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 v-if="item.icon === 'settings'" class="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>{{ 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 flex-shrink-0">
{{ auth.user?.firstName?.[0] || 'U' }}
</div>
<div 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>
<!-- Logout -->
<button
@click="logout"
class="p-2 text-pulse-muted hover:text-white transition-colors flex-shrink-0"
title="Abmelden"
class="sidebar-link w-full text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg class="w-5 h-5 flex-shrink-0" 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>
<span>Abmelden</span>
</button>
</div>
</div>
</aside>
<!-- Main Content -->

View File

@@ -18,6 +18,16 @@ const routes = [
name: 'Dashboard',
component: () => import('@/views/DashboardView.vue')
},
{
path: 'inbox',
name: 'Inbox',
component: () => import('@/views/InboxView.vue')
},
{
path: 'leads',
name: 'Leads',
component: () => import('@/views/LeadsView.vue')
},
{
path: 'contacts',
name: 'Contacts',
@@ -57,6 +67,12 @@ const routes = [
path: 'settings',
name: 'Settings',
component: () => import('@/views/SettingsView.vue')
},
{
path: 'team',
name: 'Team',
component: () => import('@/views/UsersView.vue'),
meta: { roles: ['owner', 'admin'] }
}
]
},

581
src/views/InboxView.vue Normal file
View File

@@ -0,0 +1,581 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import api from '@/lib/api'
const auth = useAuthStore()
const loading = ref(true)
const items = ref([])
const stats = ref({
total: 0,
pending: 0,
inProgress: 0,
doneToday: 0,
overdue: 0,
dueToday: 0
})
// Filters
const filterStatus = ref('all')
const filterType = ref('all')
const showTodayOnly = ref(false)
// Modal
const showModal = ref(false)
const editingItem = ref(null)
const formData = ref({
type: 'task',
title: '',
description: '',
dueDate: '',
priority: 'medium',
userId: '',
})
// Team members (for assigning tasks)
const teamMembers = ref([])
const canAssign = computed(() => ['owner', 'admin', 'manager'].includes(auth.user?.role))
const filteredItems = computed(() => {
let result = items.value
if (filterStatus.value !== 'all') {
result = result.filter(item => item.status === filterStatus.value)
}
if (filterType.value !== 'all') {
result = result.filter(item => item.type === filterType.value)
}
return result
})
const typeLabels = {
task: { label: 'Aufgabe', icon: '📋', color: 'bg-blue-500' },
appointment: { label: 'Termin', icon: '📅', color: 'bg-purple-500' },
email: { label: 'E-Mail', icon: '📧', color: 'bg-green-500' },
reminder: { label: 'Erinnerung', icon: '🔔', color: 'bg-yellow-500' },
}
const priorityLabels = {
low: { label: 'Niedrig', color: 'bg-gray-500' },
medium: { label: 'Mittel', color: 'bg-blue-500' },
high: { label: 'Hoch', color: 'bg-orange-500' },
urgent: { label: 'Dringend', color: 'bg-red-500' },
}
const statusLabels = {
pending: { label: 'Offen', color: 'bg-gray-500' },
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-500' },
done: { label: 'Erledigt', color: 'bg-green-500' },
cancelled: { label: 'Abgebrochen', color: 'bg-red-500' },
}
async function fetchInbox() {
loading.value = true
try {
const params = new URLSearchParams()
if (showTodayOnly.value) params.append('today', 'true')
const [inboxRes, statsRes] = await Promise.all([
api.get(`/inbox?${params.toString()}`),
api.get('/inbox/stats')
])
items.value = inboxRes.data.data.items
stats.value = statsRes.data.data
} catch (error) {
console.error('Failed to fetch inbox:', error)
} finally {
loading.value = false
}
}
async function fetchTeamMembers() {
if (!canAssign.value) return
try {
const res = await api.get('/users')
teamMembers.value = res.data.data.users.filter(u => u.id !== auth.user.id)
} catch (error) {
console.error('Failed to fetch team:', error)
}
}
async function updateStatus(item, newStatus) {
try {
await api.put(`/inbox/${item.id}/status`, { status: newStatus })
item.status = newStatus
// Refresh stats
const statsRes = await api.get('/inbox/stats')
stats.value = statsRes.data.data
} catch (error) {
console.error('Failed to update status:', error)
}
}
function openCreateModal() {
editingItem.value = null
formData.value = {
type: 'task',
title: '',
description: '',
dueDate: '',
priority: 'medium',
userId: '',
}
showModal.value = true
}
function openEditModal(item) {
editingItem.value = item
formData.value = {
type: item.type,
title: item.title,
description: item.description || '',
dueDate: item.dueDate ? new Date(item.dueDate).toISOString().slice(0, 16) : '',
priority: item.priority,
userId: '',
}
showModal.value = true
}
async function saveItem() {
try {
const payload = {
type: formData.value.type,
title: formData.value.title,
description: formData.value.description || null,
dueDate: formData.value.dueDate || null,
priority: formData.value.priority,
}
if (formData.value.userId) {
payload.userId = formData.value.userId
}
if (editingItem.value) {
await api.put(`/inbox/${editingItem.value.id}`, payload)
} else {
await api.post('/inbox', payload)
}
showModal.value = false
await fetchInbox()
} catch (error) {
console.error('Failed to save item:', error)
alert('Fehler beim Speichern')
}
}
async function deleteItem(item) {
if (!confirm(`"${item.title}" wirklich löschen?`)) return
try {
await api.delete(`/inbox/${item.id}`)
await fetchInbox()
} catch (error) {
console.error('Failed to delete item:', error)
}
}
function formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const today = new Date()
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
if (date.toDateString() === today.toDateString()) {
return `Heute, ${date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`
}
if (date.toDateString() === tomorrow.toDateString()) {
return `Morgen, ${date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`
}
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
function isOverdue(item) {
if (!item.dueDate || item.status === 'done' || item.status === 'cancelled') return false
return new Date(item.dueDate) < new Date()
}
onMounted(() => {
fetchInbox()
fetchTeamMembers()
})
</script>
<template>
<div class="p-6 max-w-7xl mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white flex items-center gap-3">
📥 Inbox
</h1>
<p class="text-pulse-muted mt-1">Deine Aufgaben, Termine und Nachrichten</p>
</div>
<button
@click="openCreateModal"
class="btn-primary flex items-center gap-2"
>
<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="M12 4v16m8-8H4" />
</svg>
Neuer Eintrag
</button>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
<div class="bg-pulse-card rounded-xl p-4 border border-pulse-border">
<div class="text-2xl font-bold text-white">{{ stats.dueToday }}</div>
<div class="text-sm text-pulse-muted">Heute fällig</div>
</div>
<div class="bg-pulse-card rounded-xl p-4 border border-pulse-border">
<div class="text-2xl font-bold text-white">{{ stats.pending }}</div>
<div class="text-sm text-pulse-muted">Offen</div>
</div>
<div class="bg-pulse-card rounded-xl p-4 border border-pulse-border">
<div class="text-2xl font-bold text-blue-400">{{ stats.inProgress }}</div>
<div class="text-sm text-pulse-muted">In Bearbeitung</div>
</div>
<div class="bg-pulse-card rounded-xl p-4 border border-pulse-border">
<div class="text-2xl font-bold text-green-400">{{ stats.doneToday }}</div>
<div class="text-sm text-pulse-muted">Heute erledigt</div>
</div>
<div class="bg-pulse-card rounded-xl p-4 border border-pulse-border" :class="stats.overdue > 0 ? 'border-red-500/50' : ''">
<div class="text-2xl font-bold" :class="stats.overdue > 0 ? 'text-red-400' : 'text-white'">{{ stats.overdue }}</div>
<div class="text-sm text-pulse-muted">Überfällig</div>
</div>
<div class="bg-pulse-card rounded-xl p-4 border border-pulse-border">
<div class="text-2xl font-bold text-white">{{ stats.total }}</div>
<div class="text-sm text-pulse-muted">Gesamt offen</div>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-4 mb-6">
<div class="flex items-center gap-2">
<label class="text-sm text-pulse-muted">Status:</label>
<select v-model="filterStatus" class="input-field text-sm py-1.5">
<option value="all">Alle</option>
<option value="pending">Offen</option>
<option value="in_progress">In Bearbeitung</option>
<option value="done">Erledigt</option>
</select>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-pulse-muted">Typ:</label>
<select v-model="filterType" class="input-field text-sm py-1.5">
<option value="all">Alle</option>
<option value="task">Aufgaben</option>
<option value="appointment">Termine</option>
<option value="email">E-Mails</option>
<option value="reminder">Erinnerungen</option>
</select>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="showTodayOnly"
@change="fetchInbox"
class="w-4 h-4 rounded border-pulse-border bg-pulse-bg text-primary-500 focus:ring-primary-500"
>
<span class="text-sm text-pulse-muted">Nur heute</span>
</label>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
</div>
<!-- Empty State -->
<div v-else-if="filteredItems.length === 0" class="text-center py-12">
<div class="text-5xl mb-4">📭</div>
<h3 class="text-xl font-medium text-white mb-2">Alles erledigt!</h3>
<p class="text-pulse-muted">Keine Einträge in deiner Inbox.</p>
</div>
<!-- Items List -->
<div v-else class="space-y-3">
<div
v-for="item in filteredItems"
:key="item.id"
class="bg-pulse-card rounded-xl border border-pulse-border p-4 hover:border-pulse-border-hover transition-colors"
:class="{ 'border-red-500/30 bg-red-500/5': isOverdue(item) }"
>
<div class="flex items-start gap-4">
<!-- Checkbox -->
<button
@click="updateStatus(item, item.status === 'done' ? 'pending' : 'done')"
class="mt-1 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors flex-shrink-0"
:class="item.status === 'done'
? 'bg-green-500 border-green-500 text-white'
: 'border-pulse-border hover:border-primary-500'"
>
<svg v-if="item.status === 'done'" 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>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<!-- Type Badge -->
<span
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium text-white"
:class="typeLabels[item.type]?.color"
>
{{ typeLabels[item.type]?.icon }} {{ typeLabels[item.type]?.label }}
</span>
<!-- Priority Badge -->
<span
v-if="item.priority !== 'medium'"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium text-white"
:class="priorityLabels[item.priority]?.color"
>
{{ priorityLabels[item.priority]?.label }}
</span>
<!-- Overdue Badge -->
<span v-if="isOverdue(item)" class="text-xs text-red-400 font-medium">
Überfällig
</span>
</div>
<h3
class="font-medium"
:class="item.status === 'done' ? 'text-pulse-muted line-through' : 'text-white'"
>
{{ item.title }}
</h3>
<p v-if="item.description" class="text-sm text-pulse-muted mt-1 line-clamp-2">
{{ item.description }}
</p>
<div class="flex items-center gap-4 mt-2 text-xs text-pulse-muted">
<span v-if="item.dueDate" class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ formatDate(item.dueDate) }}
</span>
<span v-if="item.assignedByName" class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Von {{ item.assignedByName }}
</span>
<span v-if="item.relatedContactName">
👤 {{ item.relatedContactName }}
</span>
<span v-if="item.relatedCompanyName">
🏢 {{ item.relatedCompanyName }}
</span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-1">
<!-- Status Dropdown -->
<select
:value="item.status"
@change="updateStatus(item, $event.target.value)"
class="input-field text-xs py-1 px-2 w-auto"
>
<option value="pending">Offen</option>
<option value="in_progress">In Bearbeitung</option>
<option value="done">Erledigt</option>
<option value="cancelled">Abgebrochen</option>
</select>
<button
@click="openEditModal(item)"
class="p-2 text-pulse-muted hover:text-white transition-colors"
title="Bearbeiten"
>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
@click="deleteItem(item)"
class="p-2 text-pulse-muted hover:text-red-400 transition-colors"
title="Löschen"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div
v-if="showModal"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
@click.self="showModal = false"
>
<div class="bg-pulse-card rounded-xl border border-pulse-border w-full max-w-lg">
<div class="p-6 border-b border-pulse-border">
<h2 class="text-xl font-bold text-white">
{{ editingItem ? 'Eintrag bearbeiten' : 'Neuer Eintrag' }}
</h2>
</div>
<form @submit.prevent="saveItem" class="p-6 space-y-4">
<!-- Type -->
<div>
<label class="block text-sm font-medium text-pulse-muted mb-2">Typ</label>
<div class="grid grid-cols-4 gap-2">
<button
v-for="(typeInfo, typeKey) in typeLabels"
:key="typeKey"
type="button"
@click="formData.type = typeKey"
class="p-3 rounded-lg border text-center transition-colors"
:class="formData.type === typeKey
? 'border-primary-500 bg-primary-500/10'
: 'border-pulse-border hover:border-pulse-border-hover'"
>
<div class="text-2xl">{{ typeInfo.icon }}</div>
<div class="text-xs text-pulse-muted mt-1">{{ typeInfo.label }}</div>
</button>
</div>
</div>
<!-- Title -->
<div>
<label class="block text-sm font-medium text-pulse-muted mb-2">Titel *</label>
<input
v-model="formData.title"
type="text"
required
class="input-field w-full"
placeholder="Was steht an?"
>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-pulse-muted mb-2">Beschreibung</label>
<textarea
v-model="formData.description"
rows="3"
class="input-field w-full"
placeholder="Details..."
></textarea>
</div>
<!-- Due Date -->
<div>
<label class="block text-sm font-medium text-pulse-muted mb-2">Fällig am</label>
<input
v-model="formData.dueDate"
type="datetime-local"
class="input-field w-full"
>
</div>
<!-- Priority -->
<div>
<label class="block text-sm font-medium text-pulse-muted mb-2">Priorität</label>
<select v-model="formData.priority" class="input-field w-full">
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="urgent">Dringend</option>
</select>
</div>
<!-- Assign to (only for managers+) -->
<div v-if="canAssign && !editingItem && teamMembers.length > 0">
<label class="block text-sm font-medium text-pulse-muted mb-2">Zuweisen an</label>
<select v-model="formData.userId" class="input-field w-full">
<option value="">Mir selbst</option>
<option v-for="member in teamMembers" :key="member.id" :value="member.id">
{{ member.firstName }} {{ member.lastName }}
</option>
</select>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<button
type="button"
@click="showModal = false"
class="btn-secondary"
>
Abbrechen
</button>
<button type="submit" class="btn-primary">
{{ editingItem ? 'Speichern' : 'Erstellen' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.btn-primary {
padding: 0.5rem 1rem;
background-color: rgb(99 102 241);
color: white;
font-weight: 500;
border-radius: 0.5rem;
transition: background-color 0.2s;
}
.btn-primary:hover {
background-color: rgb(79 70 229);
}
.btn-secondary {
padding: 0.5rem 1rem;
background-color: rgb(55 65 81);
color: white;
font-weight: 500;
border-radius: 0.5rem;
transition: background-color 0.2s;
}
.btn-secondary:hover {
background-color: rgb(75 85 99);
}
.input-field {
background-color: rgb(17 24 39);
border: 1px solid rgb(55 65 81);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
color: white;
transition: border-color 0.2s;
}
.input-field::placeholder {
color: rgb(107 114 128);
}
.input-field:focus {
outline: none;
border-color: rgb(99 102 241);
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

401
src/views/LeadsView.vue Normal file
View File

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

434
src/views/UsersView.vue Normal file
View File

@@ -0,0 +1,434 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import api from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const users = ref([])
const loading = ref(true)
const showModal = ref(false)
const editingUser = ref(null)
const saving = ref(false)
const error = ref(null)
const successMessage = ref(null)
// Form data
const form = ref({
email: '',
password: '',
firstName: '',
lastName: '',
role: 'user'
})
// Current user can create these roles
const availableRoles = computed(() => {
if (auth.user?.role === 'owner') {
return [
{ value: 'admin', label: 'CRM Admin' },
{ value: 'manager', label: 'Manager' },
{ value: 'user', label: 'Nutzer' }
]
}
// Admin can only create manager/user
return [
{ value: 'manager', label: 'Manager' },
{ value: 'user', label: 'Nutzer' }
]
})
const roleLabels = {
owner: 'CEO',
admin: 'CRM Admin',
manager: 'Manager',
user: 'Nutzer'
}
const roleColors = {
owner: 'bg-yellow-500/20 text-yellow-400',
admin: 'bg-purple-500/20 text-purple-400',
manager: 'bg-blue-500/20 text-blue-400',
user: 'bg-gray-500/20 text-gray-400'
}
async function loadUsers() {
loading.value = true
try {
const response = await api.get('/api/v1/users')
users.value = response.data.data.users
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Laden der Benutzer'
} finally {
loading.value = false
}
}
function openCreateModal() {
editingUser.value = null
form.value = {
email: '',
password: '',
firstName: '',
lastName: '',
role: 'user'
}
error.value = null
showModal.value = true
}
function openEditModal(user) {
editingUser.value = user
form.value = {
email: user.email,
password: '',
firstName: user.firstName,
lastName: user.lastName,
role: user.role
}
error.value = null
showModal.value = true
}
function closeModal() {
showModal.value = false
editingUser.value = null
error.value = null
}
async function saveUser() {
saving.value = true
error.value = null
try {
if (editingUser.value) {
// Update existing user
const updateData = {
firstName: form.value.firstName,
lastName: form.value.lastName,
role: form.value.role
}
await api.put(`/api/v1/users/${editingUser.value.id}`, updateData)
successMessage.value = 'Benutzer erfolgreich aktualisiert'
} else {
// Create new user
await api.post('/api/v1/users', form.value)
successMessage.value = 'Benutzer erfolgreich erstellt'
}
closeModal()
await loadUsers()
setTimeout(() => { successMessage.value = null }, 3000)
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Speichern'
} finally {
saving.value = false
}
}
async function toggleUserActive(user) {
try {
await api.put(`/api/v1/users/${user.id}`, { isActive: !user.isActive })
await loadUsers()
successMessage.value = user.isActive ? 'Benutzer deaktiviert' : 'Benutzer aktiviert'
setTimeout(() => { successMessage.value = null }, 3000)
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Aktualisieren'
}
}
async function deleteUser(user) {
if (!confirm(`Möchten Sie "${user.firstName} ${user.lastName}" wirklich löschen?`)) {
return
}
try {
await api.delete(`/api/v1/users/${user.id}`)
await loadUsers()
successMessage.value = 'Benutzer erfolgreich gelöscht'
setTimeout(() => { successMessage.value = null }, 3000)
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Löschen'
}
}
async function resetPassword(user) {
const password = prompt('Neues Passwort eingeben (mind. 8 Zeichen):')
if (!password || password.length < 8) {
if (password) {
error.value = 'Passwort muss mindestens 8 Zeichen haben'
}
return
}
try {
await api.post(`/api/v1/users/${user.id}/reset-password`, { password })
successMessage.value = 'Passwort erfolgreich zurückgesetzt'
setTimeout(() => { successMessage.value = null }, 3000)
} catch (e) {
error.value = e.response?.data?.error?.message || 'Fehler beim Zurücksetzen'
}
}
// Check if current user can manage this user
function canManage(user) {
if (user.role === 'owner') return false
if (auth.user?.role === 'owner') return true
if (auth.user?.role === 'admin' && ['manager', 'user'].includes(user.role)) return true
return false
}
onMounted(() => {
loadUsers()
})
</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">Team-Verwaltung</h1>
<p class="text-pulse-muted mt-1">Verwalte die Benutzer deiner Organisation</p>
</div>
<button
@click="openCreateModal"
class="btn btn-primary flex items-center gap-2"
>
<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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Benutzer einladen
</button>
</div>
<!-- Success Message -->
<div v-if="successMessage" class="mb-4 p-4 bg-green-500/20 border border-green-500/50 rounded-lg text-green-400">
{{ successMessage }}
</div>
<!-- Error Message -->
<div v-if="error && !showModal" class="mb-4 p-4 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
{{ error }}
</div>
<!-- Role Legend -->
<div class="mb-6 flex flex-wrap gap-4 text-sm">
<div class="flex items-center gap-2">
<span :class="['px-2 py-1 rounded text-xs font-medium', roleColors.owner]">CEO</span>
<span class="text-pulse-muted">Sieht alles, volle Kontrolle</span>
</div>
<div class="flex items-center gap-2">
<span :class="['px-2 py-1 rounded text-xs font-medium', roleColors.admin]">CRM Admin</span>
<span class="text-pulse-muted">Sieht alles, kann Benutzer verwalten</span>
</div>
<div class="flex items-center gap-2">
<span :class="['px-2 py-1 rounded text-xs font-medium', roleColors.manager]">Manager</span>
<span class="text-pulse-muted">Team-Zugriff</span>
</div>
<div class="flex items-center gap-2">
<span :class="['px-2 py-1 rounded text-xs font-medium', roleColors.user]">Nutzer</span>
<span class="text-pulse-muted">Eigene Daten</span>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
</div>
<!-- Users Table -->
<div v-else class="bg-pulse-card rounded-xl border border-pulse-border overflow-hidden">
<table class="w-full">
<thead>
<tr class="border-b border-pulse-border">
<th class="text-left px-6 py-4 text-pulse-muted font-medium text-sm">Benutzer</th>
<th class="text-left px-6 py-4 text-pulse-muted font-medium text-sm">Rolle</th>
<th class="text-left px-6 py-4 text-pulse-muted font-medium text-sm">Status</th>
<th class="text-left px-6 py-4 text-pulse-muted font-medium text-sm">Letzter Login</th>
<th class="text-right px-6 py-4 text-pulse-muted font-medium text-sm">Aktionen</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in users"
:key="user.id"
class="border-b border-pulse-border last:border-0 hover:bg-pulse-hover transition-colors"
>
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary-600 flex items-center justify-center text-white font-medium">
{{ user.firstName?.[0] || 'U' }}
</div>
<div>
<p class="font-medium text-white">{{ user.firstName }} {{ user.lastName }}</p>
<p class="text-sm text-pulse-muted">{{ user.email }}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<span :class="['px-3 py-1 rounded-full text-xs font-medium', roleColors[user.role]]">
{{ roleLabels[user.role] }}
</span>
</td>
<td class="px-6 py-4">
<span v-if="user.isActive" class="flex items-center gap-2 text-green-400">
<div class="w-2 h-2 rounded-full bg-green-400"></div>
Aktiv
</span>
<span v-else class="flex items-center gap-2 text-gray-400">
<div class="w-2 h-2 rounded-full bg-gray-400"></div>
Inaktiv
</span>
</td>
<td class="px-6 py-4 text-pulse-muted text-sm">
{{ user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString('de-DE') : 'Nie' }}
</td>
<td class="px-6 py-4">
<div v-if="canManage(user)" class="flex items-center justify-end gap-2">
<button
@click="openEditModal(user)"
class="p-2 text-pulse-muted hover:text-white transition-colors"
title="Bearbeiten"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<button
@click="toggleUserActive(user)"
class="p-2 text-pulse-muted hover:text-yellow-400 transition-colors"
:title="user.isActive ? 'Deaktivieren' : 'Aktivieren'"
>
<svg v-if="user.isActive" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<svg v-else class="w-4 h-4" 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>
</button>
<button
@click="resetPassword(user)"
class="p-2 text-pulse-muted hover:text-blue-400 transition-colors"
title="Passwort zurücksetzen"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</button>
<button
@click="deleteUser(user)"
class="p-2 text-pulse-muted hover:text-red-400 transition-colors"
title="Löschen"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div v-else class="text-right text-pulse-muted text-sm">
{{ user.id === auth.user?.id ? 'Du' : '—' }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create/Edit Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-pulse-card rounded-xl border border-pulse-border w-full max-w-md">
<div class="flex items-center justify-between px-6 py-4 border-b border-pulse-border">
<h2 class="text-lg font-semibold text-white">
{{ editingUser ? 'Benutzer bearbeiten' : 'Neuen Benutzer einladen' }}
</h2>
<button @click="closeModal" class="text-pulse-muted hover:text-white">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="saveUser" class="p-6 space-y-4">
<!-- Error in modal -->
<div v-if="error" class="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 text-sm">
{{ error }}
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-pulse-muted mb-1">Vorname</label>
<input
v-model="form.firstName"
type="text"
required
class="w-full px-3 py-2 bg-pulse-dark border border-pulse-border rounded-lg text-pulse-text placeholder-pulse-muted focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label class="block text-sm text-pulse-muted mb-1">Nachname</label>
<input
v-model="form.lastName"
type="text"
required
class="w-full px-3 py-2 bg-pulse-dark border border-pulse-border rounded-lg text-pulse-text placeholder-pulse-muted focus:border-primary-500 focus:outline-none"
/>
</div>
</div>
<div v-if="!editingUser">
<label class="block text-sm text-pulse-muted mb-1">E-Mail</label>
<input
v-model="form.email"
type="email"
required
class="w-full px-3 py-2 bg-pulse-dark border border-pulse-border rounded-lg text-pulse-text placeholder-pulse-muted focus:border-primary-500 focus:outline-none"
/>
</div>
<div v-if="!editingUser">
<label class="block text-sm text-pulse-muted mb-1">Passwort</label>
<input
v-model="form.password"
type="password"
required
minlength="8"
placeholder="Mind. 8 Zeichen"
class="w-full px-3 py-2 bg-pulse-dark border border-pulse-border rounded-lg text-pulse-text placeholder-pulse-muted focus:border-primary-500 focus:outline-none"
/>
</div>
<div>
<label class="block text-sm text-pulse-muted mb-1">Rolle</label>
<select
v-model="form.role"
:disabled="editingUser?.role === 'owner'"
class="w-full px-3 py-2 bg-pulse-dark border border-pulse-border rounded-lg text-pulse-text focus:border-primary-500 focus:outline-none disabled:opacity-50"
>
<option v-for="role in availableRoles" :key="role.value" :value="role.value">
{{ role.label }}
</option>
</select>
</div>
<div class="flex justify-end gap-3 pt-4">
<button
type="button"
@click="closeModal"
class="px-4 py-2 text-pulse-muted hover:text-white transition-colors"
>
Abbrechen
</button>
<button
type="submit"
:disabled="saving"
class="btn btn-primary"
>
{{ saving ? 'Speichern...' : (editingUser ? 'Speichern' : 'Einladen') }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>