Compare commits
10 Commits
01d542b6b6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5e41c2577 | ||
|
|
3d2c4e2f26 | ||
|
|
7b0fd718b0 | ||
|
|
954a1f01d6 | ||
|
|
2b059e1bfb | ||
|
|
5e1b49c4de | ||
|
|
8ae8abd7a7 | ||
|
|
5f63514fe0 | ||
|
|
e58dfc9a39 | ||
|
|
ba1646d863 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
1
dist/assets/ActivitiesView-4eRkBUJ5.js
vendored
Normal file
1
dist/assets/ActivitiesView-4eRkBUJ5.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/AppLayout-DOV1d8ZL.js
vendored
Normal file
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
1
dist/assets/CompaniesView-DDKYWHXV.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/CompanyDetailView-B0YIYDYC.js
vendored
Normal file
1
dist/assets/CompanyDetailView-B0YIYDYC.js
vendored
Normal 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};
|
||||
1
dist/assets/ContactDetailView-C9RQ0wiu.js
vendored
Normal file
1
dist/assets/ContactDetailView-C9RQ0wiu.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/ContactsView-BMha6-Fv.js
vendored
Normal file
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
1
dist/assets/DashboardView-B_vOqYxU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/DealDetailView-C_Pg-hoU.js
vendored
Normal file
1
dist/assets/DealDetailView-C_Pg-hoU.js
vendored
Normal 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
1
dist/assets/LoginView-PMaCJFIu.js
vendored
Normal 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
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
1
dist/assets/SettingsView-ByWddGMW.js
vendored
Normal 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
1
dist/assets/activities-DN88j27F.js
vendored
Normal 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
1
dist/assets/contacts-B_I94NMP.js
vendored
Normal 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
1
dist/assets/deals-Bh3SQGc9.js
vendored
Normal 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
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
1
dist/assets/index-DeJ0q2c_.js
vendored
Normal 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
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
18
dist/index.html
vendored
Normal 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
10
dist/pulse-icon.svg
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="pulse-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3b82f6"/>
|
||||
<stop offset="100%" style="stop-color:#1d4ed8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="20" fill="url(#pulse-gradient)"/>
|
||||
<path d="M30 50 L45 35 L50 50 L55 25 L60 50 L70 50" stroke="white" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 514 B |
2670
package-lock.json
generated
Normal file
2670
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,95 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const sidebarOpen = ref(true)
|
||||
const 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) {
|
||||
sidebarOpen.value = true
|
||||
} else {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
@@ -25,83 +99,207 @@ 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>
|
||||
<div class="flex h-full">
|
||||
<div class="flex h-full relative">
|
||||
<!-- Mobile Overlay -->
|
||||
<div
|
||||
v-if="isMobile && sidebarOpen"
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
@click="closeSidebarOnMobile"
|
||||
></div>
|
||||
|
||||
<!-- Mobile Header -->
|
||||
<header
|
||||
v-if="isMobile"
|
||||
class="fixed top-0 left-0 right-0 h-14 bg-pulse-card border-b border-pulse-border flex items-center px-4 z-30"
|
||||
>
|
||||
<button
|
||||
@click="toggleSidebar"
|
||||
class="p-2 text-white hover:bg-pulse-border rounded-lg transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-2 ml-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-lg font-bold text-white">Pulse</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
:class="[
|
||||
'flex flex-col bg-pulse-card border-r border-pulse-border transition-all duration-300',
|
||||
sidebarOpen ? 'w-64' : 'w-20'
|
||||
isMobile ? 'fixed top-0 left-0 h-full z-50' : '',
|
||||
isMobile && !sidebarOpen ? '-translate-x-full' : 'translate-x-0',
|
||||
'w-64'
|
||||
]"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center gap-3 px-6 py-5 border-b border-pulse-border">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<!-- 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 v-if="sidebarOpen" 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
|
||||
v-if="isMobile"
|
||||
@click="closeSidebarOnMobile"
|
||||
class="p-2 text-pulse-muted hover:text-white transition-colors"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 py-4 px-3 space-y-1">
|
||||
<!-- 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-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 navItems"
|
||||
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" 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" 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 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>
|
||||
<svg v-if="item.icon === 'building-office'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<svg v-if="item.icon === 'funnel'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
<svg v-if="item.icon === 'clipboard-list'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
<span v-if="sidebarOpen">{{ item.name }}</span>
|
||||
<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">
|
||||
{{ auth.user?.firstName?.[0] || 'U' }}
|
||||
</div>
|
||||
<div v-if="sidebarOpen" class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">
|
||||
{{ auth.user?.firstName }} {{ auth.user?.lastName }}
|
||||
</p>
|
||||
<p class="text-xs text-pulse-muted truncate">{{ auth.user?.email }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="logout"
|
||||
class="p-2 text-pulse-muted hover:text-white transition-colors"
|
||||
title="Abmelden"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Logout -->
|
||||
<button
|
||||
@click="logout"
|
||||
class="sidebar-link w-full text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
<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>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-auto">
|
||||
<main :class="['flex-1 overflow-auto', isMobile ? 'pt-14' : '']">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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'] }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -16,12 +16,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
try {
|
||||
const response = await api.post('/api/v1/auth/login', { email, password })
|
||||
token.value = response.data.token
|
||||
user.value = response.data.user
|
||||
// API returns: { success, data: { user, tokens: { accessToken, refreshToken } } }
|
||||
const data = response.data.data
|
||||
token.value = data.tokens.accessToken
|
||||
user.value = data.user
|
||||
localStorage.setItem('pulse_token', token.value)
|
||||
localStorage.setItem('pulse_refresh', data.tokens.refreshToken)
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Login error:', e)
|
||||
error.value = e.response?.data?.error?.message || 'Login fehlgeschlagen'
|
||||
return false
|
||||
} finally {
|
||||
@@ -35,12 +39,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
try {
|
||||
const response = await api.post('/api/v1/auth/register', data)
|
||||
token.value = response.data.token
|
||||
user.value = response.data.user
|
||||
// API returns: { success, data: { user, tokens: { accessToken, refreshToken } } }
|
||||
const resData = response.data.data
|
||||
token.value = resData.tokens.accessToken
|
||||
user.value = resData.user
|
||||
localStorage.setItem('pulse_token', token.value)
|
||||
localStorage.setItem('pulse_refresh', resData.tokens.refreshToken)
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Register error:', e)
|
||||
error.value = e.response?.data?.error?.message || 'Registrierung fehlgeschlagen'
|
||||
return false
|
||||
} finally {
|
||||
|
||||
@@ -62,14 +62,14 @@ const typeLabels = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="p-4 sm:p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Aktivitäten</h1>
|
||||
<p class="text-pulse-muted">Ihre Aufgaben und Aktivitäten</p>
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-white">Aktivitäten</h1>
|
||||
<p class="text-pulse-muted text-sm">Ihre Aufgaben und Aktivitäten</p>
|
||||
</div>
|
||||
<button @click="showNewModal = true" class="btn-primary">
|
||||
<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>
|
||||
@@ -78,42 +78,42 @@ const typeLabels = {
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="card p-4">
|
||||
<p class="text-2xl font-bold text-white">{{ activities.stats?.totalToday || 0 }}</p>
|
||||
<p class="text-sm text-pulse-muted">Heute fällig</p>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-6">
|
||||
<div class="card p-3 sm:p-4">
|
||||
<p class="text-xl sm:text-2xl font-bold text-white">{{ activities.stats?.totalToday || 0 }}</p>
|
||||
<p class="text-xs sm:text-sm text-pulse-muted">Heute fällig</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-2xl font-bold text-green-400">{{ activities.stats?.completedToday || 0 }}</p>
|
||||
<p class="text-sm text-pulse-muted">Heute erledigt</p>
|
||||
<div class="card p-3 sm:p-4">
|
||||
<p class="text-xl sm:text-2xl font-bold text-green-400">{{ activities.stats?.completedToday || 0 }}</p>
|
||||
<p class="text-xs sm:text-sm text-pulse-muted">Heute erledigt</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-2xl font-bold text-red-400">{{ activities.stats?.overdue || 0 }}</p>
|
||||
<p class="text-sm text-pulse-muted">Überfällig</p>
|
||||
<div class="card p-3 sm:p-4">
|
||||
<p class="text-xl sm:text-2xl font-bold text-red-400">{{ activities.stats?.overdue || 0 }}</p>
|
||||
<p class="text-xs sm:text-sm text-pulse-muted">Überfällig</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-2xl font-bold text-blue-400">{{ activities.stats?.upcoming || 0 }}</p>
|
||||
<p class="text-sm text-pulse-muted">Diese Woche</p>
|
||||
<div class="card p-3 sm:p-4">
|
||||
<p class="text-xl sm:text-2xl font-bold text-blue-400">{{ activities.stats?.upcoming || 0 }}</p>
|
||||
<p class="text-xs sm:text-sm text-pulse-muted">Diese Woche</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
<div class="flex gap-2 mb-6 overflow-x-auto pb-2 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<button
|
||||
@click="filter = 'all'; activities.fetchActivities()"
|
||||
:class="['btn-sm', filter === 'all' ? 'btn-primary' : 'btn-ghost']"
|
||||
:class="['btn-sm whitespace-nowrap', filter === 'all' ? 'btn-primary' : 'btn-ghost']"
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
<button
|
||||
@click="filter = 'upcoming'; activities.fetchActivities({ isCompleted: 'false' })"
|
||||
:class="['btn-sm', filter === 'upcoming' ? 'btn-primary' : 'btn-ghost']"
|
||||
:class="['btn-sm whitespace-nowrap', filter === 'upcoming' ? 'btn-primary' : 'btn-ghost']"
|
||||
>
|
||||
Offen
|
||||
</button>
|
||||
<button
|
||||
@click="filter = 'completed'; activities.fetchActivities({ isCompleted: 'true' })"
|
||||
:class="['btn-sm', filter === 'completed' ? 'btn-primary' : 'btn-ghost']"
|
||||
:class="['btn-sm whitespace-nowrap', filter === 'completed' ? 'btn-primary' : 'btn-ghost']"
|
||||
>
|
||||
Erledigt
|
||||
</button>
|
||||
@@ -124,13 +124,13 @@ const typeLabels = {
|
||||
<div
|
||||
v-for="activity in activities.activities"
|
||||
:key="activity.id"
|
||||
class="p-4 flex items-center gap-4 hover:bg-pulse-dark/30 transition-colors"
|
||||
class="p-3 sm:p-4 flex items-start sm:items-center gap-3 sm:gap-4 hover:bg-pulse-dark/30 transition-colors"
|
||||
>
|
||||
<!-- Checkbox -->
|
||||
<button
|
||||
@click.stop="completeActivity(activity.id)"
|
||||
:class="[
|
||||
'w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors',
|
||||
'w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors flex-shrink-0 mt-0.5 sm:mt-0',
|
||||
activity.isCompleted
|
||||
? 'bg-green-500 border-green-500 text-white'
|
||||
: 'border-pulse-border hover:border-primary-500'
|
||||
@@ -141,26 +141,41 @@ const typeLabels = {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Type Icon -->
|
||||
<span class="text-xl">{{ typeIcons[activity.type] }}</span>
|
||||
<!-- Type Icon (hidden on very small screens) -->
|
||||
<span class="text-xl hidden sm:block">{{ typeIcons[activity.type] }}</span>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p :class="['font-medium', activity.isCompleted ? 'text-pulse-muted line-through' : 'text-white']">
|
||||
{{ activity.subject }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-sm text-pulse-muted">
|
||||
<div class="flex items-start sm:items-center gap-2">
|
||||
<span class="text-base sm:hidden">{{ typeIcons[activity.type] }}</span>
|
||||
<p :class="['font-medium text-sm sm:text-base', activity.isCompleted ? 'text-pulse-muted line-through' : 'text-white']">
|
||||
{{ activity.subject }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1 sm:gap-2 text-xs sm:text-sm text-pulse-muted mt-1">
|
||||
<span>{{ typeLabels[activity.type] }}</span>
|
||||
<span v-if="activity.contact">· {{ activity.contact.name }}</span>
|
||||
<span v-if="activity.company">· {{ activity.company.name }}</span>
|
||||
</div>
|
||||
<!-- Due date on mobile -->
|
||||
<span
|
||||
v-if="activity.dueDate"
|
||||
:class="[
|
||||
'text-xs mt-1 inline-block sm:hidden',
|
||||
new Date(activity.dueDate) < new Date() && !activity.isCompleted
|
||||
? 'text-red-400'
|
||||
: 'text-pulse-muted'
|
||||
]"
|
||||
>
|
||||
📅 {{ formatDate(activity.dueDate) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Due Date -->
|
||||
<!-- Due Date (desktop) -->
|
||||
<span
|
||||
v-if="activity.dueDate"
|
||||
:class="[
|
||||
'text-sm',
|
||||
'text-sm hidden sm:block flex-shrink-0',
|
||||
new Date(activity.dueDate) < new Date() && !activity.isCompleted
|
||||
? 'text-red-400'
|
||||
: 'text-pulse-muted'
|
||||
@@ -171,20 +186,27 @@ const typeLabels = {
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!activities.loading && !activities.activities.length" class="p-12 text-center">
|
||||
<div v-if="!activities.loading && !activities.activities.length" class="p-8 sm:p-12 text-center">
|
||||
<p class="text-pulse-muted">Keine Aktivitäten gefunden</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Activity Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showNewModal" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div v-if="showNewModal" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4">
|
||||
<div class="absolute inset-0 bg-black/60" @click="showNewModal = false"></div>
|
||||
<div class="relative card w-full max-w-md mx-4">
|
||||
<div class="px-6 py-4 border-b border-pulse-border">
|
||||
<h2 class="text-lg font-semibold text-white">Neue Aktivität</h2>
|
||||
<div class="relative card w-full sm:max-w-md rounded-b-none sm:rounded-b-xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="px-4 sm:px-6 py-4 border-b border-pulse-border sticky top-0 bg-pulse-card z-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Neue Aktivität</h2>
|
||||
<button @click="showNewModal = false" class="p-2 text-pulse-muted hover:text-white sm:hidden">
|
||||
<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>
|
||||
</div>
|
||||
<form @submit.prevent="createActivity" class="p-6 space-y-4">
|
||||
<form @submit.prevent="createActivity" class="p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<label class="label">Typ</label>
|
||||
<select v-model="newActivity.type" class="input">
|
||||
@@ -203,7 +225,7 @@ const typeLabels = {
|
||||
<label class="label">Fällig am</label>
|
||||
<input v-model="newActivity.dueDate" type="datetime-local" class="input" />
|
||||
</div>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<div class="flex flex-col-reverse sm:flex-row gap-3 pt-2">
|
||||
<button type="button" @click="showNewModal = false" class="btn-secondary flex-1">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
@@ -54,14 +54,14 @@ async function createCompany() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="p-4 sm:p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Firmen</h1>
|
||||
<p class="text-pulse-muted">{{ meta.total }} Firmen</p>
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-white">Firmen</h1>
|
||||
<p class="text-pulse-muted text-sm">{{ meta.total }} Firmen</p>
|
||||
</div>
|
||||
<button @click="showNewModal = true" class="btn-primary">
|
||||
<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>
|
||||
@@ -71,48 +71,51 @@ async function createCompany() {
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-6">
|
||||
<div class="relative max-w-md">
|
||||
<div class="relative w-full 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"
|
||||
class="input pl-10 w-full"
|
||||
placeholder="Suchen..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
<div
|
||||
v-for="company in companies"
|
||||
:key="company.id"
|
||||
class="card p-5 cursor-pointer hover:border-primary-500 transition-colors"
|
||||
class="card p-4 sm:p-5 cursor-pointer hover:border-primary-500 active:scale-[0.98] transition-all"
|
||||
@click="router.push(`/companies/${company.id}`)"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-12 h-12 rounded-xl bg-purple-600/20 flex items-center justify-center text-purple-400 font-bold text-lg">
|
||||
<div class="flex items-start gap-3 sm:gap-4">
|
||||
<div class="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-purple-600/20 flex items-center justify-center text-purple-400 font-bold text-base sm:text-lg flex-shrink-0">
|
||||
{{ company.name?.[0]?.toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-white truncate">{{ company.name }}</h3>
|
||||
<p v-if="company.industry" class="text-sm text-pulse-muted">{{ company.industry }}</p>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span v-if="company.size" class="badge-gray">{{ company.size }}</span>
|
||||
<p v-if="company.industry" class="text-sm text-pulse-muted truncate">{{ company.industry }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span v-if="company.size" class="badge-gray text-xs">{{ company.size }}</span>
|
||||
<span v-if="company.contactCount" class="text-xs text-pulse-muted">
|
||||
{{ company.contactCount }} Kontakte
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-pulse-muted flex-shrink-0 hidden sm:block" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!loading && !companies.length" class="card p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-pulse-muted opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<div v-if="!loading && !companies.length" class="card p-8 sm:p-12 text-center">
|
||||
<svg class="w-12 sm:w-16 h-12 sm:h-16 mx-auto mb-4 text-pulse-muted opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<p class="text-pulse-muted">Noch keine Firmen vorhanden</p>
|
||||
@@ -123,13 +126,20 @@ async function createCompany() {
|
||||
|
||||
<!-- New Company Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showNewModal" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div v-if="showNewModal" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4">
|
||||
<div class="absolute inset-0 bg-black/60" @click="showNewModal = false"></div>
|
||||
<div class="relative card w-full max-w-lg mx-4">
|
||||
<div class="px-6 py-4 border-b border-pulse-border">
|
||||
<h2 class="text-lg font-semibold text-white">Neue Firma</h2>
|
||||
<div class="relative card w-full sm:max-w-lg rounded-b-none sm:rounded-b-xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="px-4 sm:px-6 py-4 border-b border-pulse-border sticky top-0 bg-pulse-card z-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Neue Firma</h2>
|
||||
<button @click="showNewModal = false" class="p-2 text-pulse-muted hover:text-white sm:hidden">
|
||||
<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>
|
||||
</div>
|
||||
<form @submit.prevent="createCompany" class="p-6 space-y-4">
|
||||
<form @submit.prevent="createCompany" class="p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<label class="label">Firmenname *</label>
|
||||
<input v-model="newCompany.name" type="text" class="input" required />
|
||||
@@ -153,7 +163,7 @@ async function createCompany() {
|
||||
<option value="1000+">1000+ Mitarbeiter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<div class="flex flex-col-reverse sm:flex-row gap-3 pt-2">
|
||||
<button type="button" @click="showNewModal = false" class="btn-secondary flex-1">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
@@ -46,14 +46,14 @@ function getInitials(contact) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="p-4 sm:p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Kontakte</h1>
|
||||
<p class="text-pulse-muted">{{ contacts.meta.total }} Kontakte</p>
|
||||
<h1 class="text-xl sm:text-2xl font-bold text-white">Kontakte</h1>
|
||||
<p class="text-pulse-muted text-sm">{{ contacts.meta.total }} Kontakte</p>
|
||||
</div>
|
||||
<button @click="showNewModal = true" class="btn-primary">
|
||||
<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>
|
||||
@@ -63,21 +63,21 @@ function getInitials(contact) {
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-6">
|
||||
<div class="relative max-w-md">
|
||||
<div class="relative w-full 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"
|
||||
class="input pl-10 w-full"
|
||||
placeholder="Suchen..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card">
|
||||
<!-- Desktop Table -->
|
||||
<div class="card hidden md:block">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
@@ -98,7 +98,7 @@ function getInitials(contact) {
|
||||
>
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
<div class="w-9 h-9 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-medium flex-shrink-0">
|
||||
{{ getInitials(contact) }}
|
||||
</div>
|
||||
<div>
|
||||
@@ -117,29 +117,84 @@ function getInitials(contact) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!contacts.loading && !contacts.contacts.length" class="p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-pulse-muted opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<p class="text-pulse-muted">Noch keine Kontakte vorhanden</p>
|
||||
<button @click="showNewModal = true" class="btn-primary mt-4">
|
||||
Ersten Kontakt anlegen
|
||||
</button>
|
||||
<!-- Mobile Card List -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<div
|
||||
v-for="contact in contacts.contacts"
|
||||
:key="contact.id"
|
||||
class="card p-4 cursor-pointer active:scale-[0.98] transition-transform"
|
||||
@click="router.push(`/contacts/${contact.id}`)"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-medium flex-shrink-0">
|
||||
{{ getInitials(contact) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-white">{{ contact.firstName }} {{ contact.lastName }}</p>
|
||||
<p v-if="contact.position" class="text-sm text-pulse-muted">{{ contact.position }}</p>
|
||||
<p v-if="contact.company" class="text-sm text-primary-400">{{ contact.company.name }}</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<a
|
||||
v-if="contact.email"
|
||||
:href="`mailto:${contact.email}`"
|
||||
@click.stop
|
||||
class="inline-flex items-center gap-1 text-xs text-pulse-muted hover:text-white px-2 py-1 bg-pulse-dark rounded"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{{ contact.email }}
|
||||
</a>
|
||||
<a
|
||||
v-if="contact.phone"
|
||||
:href="`tel:${contact.phone}`"
|
||||
@click.stop
|
||||
class="inline-flex items-center gap-1 text-xs text-pulse-muted hover:text-white px-2 py-1 bg-pulse-dark rounded"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
{{ contact.phone }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-pulse-muted flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!contacts.loading && !contacts.contacts.length" class="card p-8 sm:p-12 text-center">
|
||||
<svg class="w-12 sm:w-16 h-12 sm:h-16 mx-auto mb-4 text-pulse-muted opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<p class="text-pulse-muted">Noch keine Kontakte vorhanden</p>
|
||||
<button @click="showNewModal = true" class="btn-primary mt-4">
|
||||
Ersten Kontakt anlegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New Contact Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showNewModal" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div v-if="showNewModal" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4">
|
||||
<div class="absolute inset-0 bg-black/60" @click="showNewModal = false"></div>
|
||||
<div class="relative card w-full max-w-lg mx-4">
|
||||
<div class="px-6 py-4 border-b border-pulse-border">
|
||||
<h2 class="text-lg font-semibold text-white">Neuer Kontakt</h2>
|
||||
<div class="relative card w-full sm:max-w-lg rounded-b-none sm:rounded-b-xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="px-4 sm:px-6 py-4 border-b border-pulse-border sticky top-0 bg-pulse-card z-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Neuer Kontakt</h2>
|
||||
<button @click="showNewModal = false" class="p-2 text-pulse-muted hover:text-white sm:hidden">
|
||||
<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>
|
||||
</div>
|
||||
<form @submit.prevent="createContact" class="p-6 space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<form @submit.prevent="createContact" class="p-4 sm:p-6 space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="label">Vorname *</label>
|
||||
<input v-model="newContact.firstName" type="text" class="input" required />
|
||||
@@ -161,7 +216,7 @@ function getInitials(contact) {
|
||||
<label class="label">Position</label>
|
||||
<input v-model="newContact.position" type="text" class="input" placeholder="z.B. Geschäftsführer" />
|
||||
</div>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<div class="flex flex-col-reverse sm:flex-row gap-3 pt-2">
|
||||
<button type="button" @click="showNewModal = false" class="btn-secondary flex-1">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
581
src/views/InboxView.vue
Normal file
581
src/views/InboxView.vue
Normal 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
401
src/views/LeadsView.vue
Normal 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>
|
||||
@@ -5,6 +5,7 @@ import { useDealsStore } from '@/stores/deals'
|
||||
const deals = useDealsStore()
|
||||
const showNewDealModal = ref(false)
|
||||
const draggedDeal = ref(null)
|
||||
const mobileSelectedStage = ref(null)
|
||||
|
||||
const newDeal = ref({
|
||||
title: '',
|
||||
@@ -18,6 +19,10 @@ onMounted(async () => {
|
||||
await deals.fetchPipelines()
|
||||
if (deals.currentPipeline) {
|
||||
await deals.fetchKanban(deals.currentPipeline.id)
|
||||
// Set first stage as default for mobile
|
||||
if (deals.kanbanData.stages?.length) {
|
||||
mobileSelectedStage.value = deals.kanbanData.stages[0].id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -73,17 +78,21 @@ function calculateStageValue(stageId) {
|
||||
const stageDeals = deals.kanbanData.deals[stageId] || []
|
||||
return stageDeals.reduce((sum, d) => sum + (d.value || 0), 0)
|
||||
}
|
||||
|
||||
function getStageDealsCount(stageId) {
|
||||
return (deals.kanbanData.deals[stageId] || []).length
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-pulse-border bg-pulse-card">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-4 sm:px-6 py-4 border-b border-pulse-border bg-pulse-card">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-white">Sales Pipeline</h1>
|
||||
<p class="text-sm text-pulse-muted">{{ deals.currentPipeline?.name || 'Loading...' }}</p>
|
||||
</div>
|
||||
<button @click="showNewDealModal = true" class="btn-primary">
|
||||
<button @click="showNewDealModal = 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>
|
||||
@@ -91,16 +100,97 @@ function calculateStageValue(stageId) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Board -->
|
||||
<div class="flex-1 overflow-x-auto p-6">
|
||||
<div v-if="deals.loading" class="flex items-center justify-center h-64">
|
||||
<svg class="animate-spin h-8 w-8 text-primary-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Loading -->
|
||||
<div v-if="deals.loading" class="flex-1 flex items-center justify-center">
|
||||
<svg class="animate-spin h-8 w-8 text-primary-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex gap-4 h-full min-w-max">
|
||||
<!-- Mobile Stage Tabs -->
|
||||
<div v-if="!deals.loading" class="md:hidden border-b border-pulse-border bg-pulse-card">
|
||||
<div class="flex overflow-x-auto px-4 py-2 gap-2 -mx-4">
|
||||
<button
|
||||
v-for="(stage, index) in deals.kanbanData.stages"
|
||||
:key="stage.id"
|
||||
@click="mobileSelectedStage = stage.id"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg whitespace-nowrap transition-colors flex-shrink-0',
|
||||
mobileSelectedStage === stage.id
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-pulse-dark text-pulse-muted'
|
||||
]"
|
||||
>
|
||||
<div :class="['w-2 h-2 rounded-full', getStageColor(index, deals.kanbanData.stages.length)]"></div>
|
||||
<span class="text-sm font-medium">{{ stage.name }}</span>
|
||||
<span class="text-xs bg-black/20 px-1.5 py-0.5 rounded">
|
||||
{{ getStageDealsCount(stage.id) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Deals List -->
|
||||
<div v-if="!deals.loading" class="md:hidden flex-1 overflow-y-auto p-4">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<p class="text-sm text-pulse-muted">
|
||||
{{ formatCurrency(calculateStageValue(mobileSelectedStage)) }} in dieser Phase
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="deal in deals.kanbanData.deals[mobileSelectedStage] || []"
|
||||
:key="deal.id"
|
||||
class="card p-4 active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h4 class="font-medium text-white">{{ deal.title }}</h4>
|
||||
<span class="text-sm font-semibold text-green-400">
|
||||
{{ formatCurrency(deal.value) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="deal.company" class="text-sm text-pulse-muted mb-1">
|
||||
🏢 {{ deal.company.name }}
|
||||
</div>
|
||||
|
||||
<div v-if="deal.contact" class="text-sm text-pulse-muted mb-2">
|
||||
👤 {{ deal.contact.name }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span v-if="deal.expectedCloseDate" class="text-pulse-muted">
|
||||
📅 {{ new Date(deal.expectedCloseDate).toLocaleDateString('de-DE') }}
|
||||
</span>
|
||||
<span v-if="deal.probability" :class="[
|
||||
'px-2 py-0.5 rounded-full',
|
||||
deal.probability >= 70 ? 'bg-green-500/20 text-green-400' :
|
||||
deal.probability >= 40 ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
'bg-red-500/20 text-red-400'
|
||||
]">
|
||||
{{ deal.probability }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!(deals.kanbanData.deals[mobileSelectedStage] || []).length"
|
||||
class="flex flex-col items-center justify-center py-12 text-pulse-muted"
|
||||
>
|
||||
<svg class="w-12 h-12 mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||
</svg>
|
||||
<p>Keine Deals in dieser Phase</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Kanban Board -->
|
||||
<div v-if="!deals.loading" class="hidden md:block flex-1 overflow-x-auto p-6">
|
||||
<div class="flex gap-4 h-full min-w-max">
|
||||
<div
|
||||
v-for="(stage, index) in deals.kanbanData.stages"
|
||||
:key="stage.id"
|
||||
@@ -114,7 +204,7 @@ function calculateStageValue(stageId) {
|
||||
<div :class="['w-3 h-3 rounded-full', getStageColor(index, deals.kanbanData.stages.length)]"></div>
|
||||
<h3 class="font-semibold text-white">{{ stage.name }}</h3>
|
||||
<span class="text-xs text-pulse-muted bg-pulse-card px-2 py-0.5 rounded-full">
|
||||
{{ (deals.kanbanData.deals[stage.id] || []).length }}
|
||||
{{ getStageDealsCount(stage.id) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-pulse-muted">
|
||||
@@ -178,13 +268,20 @@ function calculateStageValue(stageId) {
|
||||
|
||||
<!-- New Deal Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showNewDealModal" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div v-if="showNewDealModal" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4">
|
||||
<div class="absolute inset-0 bg-black/60" @click="showNewDealModal = false"></div>
|
||||
<div class="relative card w-full max-w-md mx-4">
|
||||
<div class="px-6 py-4 border-b border-pulse-border">
|
||||
<h2 class="text-lg font-semibold text-white">Neuer Deal</h2>
|
||||
<div class="relative card w-full sm:max-w-md rounded-b-none sm:rounded-b-xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="px-4 sm:px-6 py-4 border-b border-pulse-border sticky top-0 bg-pulse-card z-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">Neuer Deal</h2>
|
||||
<button @click="showNewDealModal = false" class="p-2 text-pulse-muted hover:text-white sm:hidden">
|
||||
<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>
|
||||
</div>
|
||||
<form @submit.prevent="createDeal" class="p-6 space-y-4">
|
||||
<form @submit.prevent="createDeal" class="p-4 sm:p-6 space-y-4">
|
||||
<div>
|
||||
<label class="label">Titel *</label>
|
||||
<input v-model="newDeal.title" type="text" class="input" placeholder="z.B. Website Redesign" required />
|
||||
@@ -197,7 +294,7 @@ function calculateStageValue(stageId) {
|
||||
<label class="label">Erwarteter Abschluss</label>
|
||||
<input v-model="newDeal.expectedCloseDate" type="date" class="input" />
|
||||
</div>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<div class="flex flex-col-reverse sm:flex-row gap-3 pt-2">
|
||||
<button type="button" @click="showNewDealModal = false" class="btn-secondary flex-1">
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
434
src/views/UsersView.vue
Normal file
434
src/views/UsersView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user