feat: Add Inbox view with task/appointment/email management

- Add InboxView.vue with full CRUD UI
- Add inbox to navigation (first item)
- Support filtering by status/type
- Show stats (due today, overdue, etc.)
- Quick status updates with checkbox
- Priority indicators and overdue warnings
This commit is contained in:
FluxKit
2026-02-25 13:02:04 +00:00
parent 5f63514fe0
commit 8ae8abd7a7
24 changed files with 3320 additions and 9 deletions

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

View File

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

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

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

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

File diff suppressed because one or more lines are too long

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

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

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

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

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

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

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

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

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

File diff suppressed because one or more lines are too long

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

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

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

File diff suppressed because one or more lines are too long

18
dist/index.html vendored Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de" class="h-full">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/pulse-icon.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Pulse CRM - Moderne Kundenbeziehungen">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<title>Pulse CRM</title>
<script type="module" crossorigin src="/assets/index-DWNalbut.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dvjxh_uJ.css">
</head>
<body class="h-full bg-pulse-dark text-pulse-text antialiased">
<div id="app" class="h-full"></div>
</body>
</html>

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

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

After

Width:  |  Height:  |  Size: 514 B

2670
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -10,6 +10,7 @@ const sidebarOpen = ref(false)
const isMobile = ref(false) const isMobile = ref(false)
const baseNavItems = [ const baseNavItems = [
{ name: 'Inbox', path: '/inbox', icon: 'inbox' },
{ name: 'Dashboard', path: '/', icon: 'chart-pie' }, { name: 'Dashboard', path: '/', icon: 'chart-pie' },
{ name: 'Kontakte', path: '/contacts', icon: 'users' }, { name: 'Kontakte', path: '/contacts', icon: 'users' },
{ name: 'Firmen', path: '/companies', icon: 'building-office' }, { name: 'Firmen', path: '/companies', icon: 'building-office' },
@@ -138,6 +139,9 @@ function isActive(path) {
@click="closeSidebarOnMobile" @click="closeSidebarOnMobile"
> >
<!-- Icons --> <!-- 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 === 'chart-pie'" class="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg v-if="item.icon === 'chart-pie'" class="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />

View File

@@ -18,6 +18,11 @@ const routes = [
name: 'Dashboard', name: 'Dashboard',
component: () => import('@/views/DashboardView.vue') component: () => import('@/views/DashboardView.vue')
}, },
{
path: 'inbox',
name: 'Inbox',
component: () => import('@/views/InboxView.vue')
},
{ {
path: 'contacts', path: 'contacts',
name: 'Contacts', name: 'Contacts',

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

@@ -0,0 +1,553 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import api from '@/services/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 {
@apply px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white font-medium rounded-lg transition-colors;
}
.btn-secondary {
@apply px-4 py-2 bg-pulse-border hover:bg-pulse-border-hover text-white font-medium rounded-lg transition-colors;
}
.input-field {
@apply bg-pulse-bg border border-pulse-border rounded-lg px-3 py-2 text-white placeholder-pulse-muted focus:outline-none focus:border-primary-500 transition-colors;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>