feat: App lock with biometric/PIN

- LockScreen component with PIN pad
- SecuritySettings for setup
- Biometric (WebAuthn) support
- PIN fallback (6 digits)
- Auto-lock after 30s in background
- Lock on app start if enabled
- Settings page integration
This commit is contained in:
2026-03-12 20:54:53 +00:00
parent 21c88be74f
commit e5d09e9c80
26 changed files with 720 additions and 53 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{d as D,u as S,q as M,m as h,c as l,a as o,t as u,F,x as A,A as C,r as v,k as m,o as i,n as L}from"./index-DFiGttzi.js";const O={class:"space-y-6"},V={class:"card"},E={class:"flex items-center justify-between mb-6"},I={class:"text-lg font-semibold"},W={key:0,class:"text-center py-8 text-gray-500"},Y={key:1,class:"grid grid-cols-7 gap-2"},B={class:"text-xs text-gray-500 mb-1"},N=["onClick"],j=D({__name:"AvailabilityView",setup(T){const f=S(),a=v(new Date),y=v([]),g=v(!0),p=m(()=>{const n=a.value.getFullYear(),e=a.value.getMonth(),t=new Date(n,e+1,0).getDate();return Array.from({length:t},(s,r)=>{const d=new Date(n,e,r+1);return{date:d.toISOString().split("T")[0],dayOfWeek:d.toLocaleDateString("de-DE",{weekday:"short"}),day:r+1,isWeekend:d.getDay()===0||d.getDay()===6}})}),_=m(()=>a.value.toLocaleDateString("de-DE",{month:"long",year:"numeric"}));M(c);async function c(){g.value=!0;const n=a.value.getFullYear(),e=a.value.getMonth(),t=new Date(n,e,1).toISOString().split("T")[0],s=new Date(n,e+1,0).toISOString().split("T")[0];try{const r=await h.get(`/availability?from=${t}&to=${s}`);y.value=r.data.availability}catch(r){console.error(r)}finally{g.value=!1}}function b(n){return y.value.find(e=>{var t;return e.date===n&&e.user_id===((t=f.user)==null?void 0:t.id)})}async function k(n){const e=b(n),t=!(e!=null&&e.available);try{await h.post("/availability",{date:n,available:t}),await c()}catch(s){alert(s instanceof Error?s.message:"Fehler")}}function x(){a.value=new Date(a.value.getFullYear(),a.value.getMonth()-1),c()}function w(){a.value=new Date(a.value.getFullYear(),a.value.getMonth()+1),c()}return(n,e)=>(i(),l("div",O,[e[1]||(e[1]=o("h1",{class:"text-2xl font-bold text-gray-900 dark:text-white"},"📅 Verfügbarkeit",-1)),o("div",V,[o("div",E,[o("button",{class:"btn btn-secondary",onClick:x},"←"),o("h2",I,u(_.value),1),o("button",{class:"btn btn-secondary",onClick:w},"→")]),g.value?(i(),l("div",W,"Lädt...")):(i(),l("div",Y,[(i(!0),l(F,null,A(p.value,t=>{var s;return i(),l("div",{key:t.date,class:"text-center"},[o("div",B,u(t.dayOfWeek),1),o("button",{class:L(["w-10 h-10 rounded-lg font-medium transition-colors",t.isWeekend?"bg-gray-100 dark:bg-gray-700":"",(s=b(t.date))!=null&&s.available?"bg-green-500 text-white hover:bg-green-600":"bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500"]),onClick:r=>k(t.date)},u(t.day),11,N)])}),128))])),e[0]||(e[0]=C('<div class="mt-6 flex items-center gap-4 text-sm"><div class="flex items-center gap-2"><span class="w-4 h-4 bg-green-500 rounded"></span><span>Verfügbar</span></div><div class="flex items-center gap-2"><span class="w-4 h-4 bg-gray-200 dark:bg-gray-600 rounded"></span><span>Nicht gemeldet</span></div></div>',1))])]))}});export{j as default};
import{d as D,u as S,q as M,m as h,c as l,a as o,t as u,F,x as A,A as C,r as v,k as m,o as i,n as L}from"./index-OvQoqblD.js";const O={class:"space-y-6"},V={class:"card"},E={class:"flex items-center justify-between mb-6"},I={class:"text-lg font-semibold"},W={key:0,class:"text-center py-8 text-gray-500"},Y={key:1,class:"grid grid-cols-7 gap-2"},B={class:"text-xs text-gray-500 mb-1"},N=["onClick"],j=D({__name:"AvailabilityView",setup(T){const f=S(),a=v(new Date),y=v([]),g=v(!0),p=m(()=>{const n=a.value.getFullYear(),e=a.value.getMonth(),t=new Date(n,e+1,0).getDate();return Array.from({length:t},(s,r)=>{const d=new Date(n,e,r+1);return{date:d.toISOString().split("T")[0],dayOfWeek:d.toLocaleDateString("de-DE",{weekday:"short"}),day:r+1,isWeekend:d.getDay()===0||d.getDay()===6}})}),_=m(()=>a.value.toLocaleDateString("de-DE",{month:"long",year:"numeric"}));M(c);async function c(){g.value=!0;const n=a.value.getFullYear(),e=a.value.getMonth(),t=new Date(n,e,1).toISOString().split("T")[0],s=new Date(n,e+1,0).toISOString().split("T")[0];try{const r=await h.get(`/availability?from=${t}&to=${s}`);y.value=r.data.availability}catch(r){console.error(r)}finally{g.value=!1}}function b(n){return y.value.find(e=>{var t;return e.date===n&&e.user_id===((t=f.user)==null?void 0:t.id)})}async function k(n){const e=b(n),t=!(e!=null&&e.available);try{await h.post("/availability",{date:n,available:t}),await c()}catch(s){alert(s instanceof Error?s.message:"Fehler")}}function x(){a.value=new Date(a.value.getFullYear(),a.value.getMonth()-1),c()}function w(){a.value=new Date(a.value.getFullYear(),a.value.getMonth()+1),c()}return(n,e)=>(i(),l("div",O,[e[1]||(e[1]=o("h1",{class:"text-2xl font-bold text-gray-900 dark:text-white"},"📅 Verfügbarkeit",-1)),o("div",V,[o("div",E,[o("button",{class:"btn btn-secondary",onClick:x},"←"),o("h2",I,u(_.value),1),o("button",{class:"btn btn-secondary",onClick:w},"→")]),g.value?(i(),l("div",W,"Lädt...")):(i(),l("div",Y,[(i(!0),l(F,null,A(p.value,t=>{var s;return i(),l("div",{key:t.date,class:"text-center"},[o("div",B,u(t.dayOfWeek),1),o("button",{class:L(["w-10 h-10 rounded-lg font-medium transition-colors",t.isWeekend?"bg-gray-100 dark:bg-gray-700":"",(s=b(t.date))!=null&&s.available?"bg-green-500 text-white hover:bg-green-600":"bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500"]),onClick:r=>k(t.date)},u(t.day),11,N)])}),128))])),e[0]||(e[0]=C('<div class="mt-6 flex items-center gap-4 text-sm"><div class="flex items-center gap-2"><span class="w-4 h-4 bg-green-500 rounded"></span><span>Verfügbar</span></div><div class="flex items-center gap-2"><span class="w-4 h-4 bg-gray-200 dark:bg-gray-600 rounded"></span><span>Nicht gemeldet</span></div></div>',1))])]))}});export{j as default};

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{d as K,c as a,a as n,e as w,b as C,v as F,F as A,x,t as r,r as f,k as m,o as s}from"./index-DFiGttzi.js";const G={class:"space-y-6"},E={class:"flex items-center justify-between"},V={class:"flex items-center gap-3"},W={class:"relative"},N={key:0,class:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"},$=["onClick"],Z={class:"text-3xl mb-3"},I={class:"text-lg font-semibold mb-1 text-gray-900 dark:text-white"},L={class:"text-sm text-gray-500 dark:text-gray-400"},T={class:"text-xs text-gray-400 mt-2"},H={key:1,class:"space-y-4"},O={class:"card bg-gray-50 dark:bg-gray-800"},R={class:"flex items-center gap-3"},q={class:"text-3xl"},j={class:"text-xl font-semibold text-gray-900 dark:text-white"},U={class:"text-sm text-gray-500 dark:text-gray-400"},J={class:"grid gap-3"},Q=["onClick"],X={class:"text-2xl"},Y={class:"font-medium text-gray-900 dark:text-white"},ee={key:2,class:"card"},ne={class:"flex items-center gap-3 mb-6 pb-4 border-b dark:border-gray-700"},te={class:"text-3xl"},ie={class:"text-xl font-semibold text-gray-900 dark:text-white"},re=["innerHTML"],le=K({__name:"HelpView",setup(ae){const l=f(""),u=f(null),o=f(null),g=[{id:"getting-started",name:"Erste Schritte",icon:"🚀",description:"Grundlagen und Einrichtung",articles:[{id:"overview",title:"Übersicht",icon:"📋",content:`
import{d as K,c as a,a as n,e as w,b as C,v as F,F as A,x,t as r,r as f,k as m,o as s}from"./index-OvQoqblD.js";const G={class:"space-y-6"},E={class:"flex items-center justify-between"},V={class:"flex items-center gap-3"},W={class:"relative"},N={key:0,class:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"},$=["onClick"],Z={class:"text-3xl mb-3"},I={class:"text-lg font-semibold mb-1 text-gray-900 dark:text-white"},L={class:"text-sm text-gray-500 dark:text-gray-400"},T={class:"text-xs text-gray-400 mt-2"},H={key:1,class:"space-y-4"},O={class:"card bg-gray-50 dark:bg-gray-800"},R={class:"flex items-center gap-3"},q={class:"text-3xl"},j={class:"text-xl font-semibold text-gray-900 dark:text-white"},U={class:"text-sm text-gray-500 dark:text-gray-400"},J={class:"grid gap-3"},Q=["onClick"],X={class:"text-2xl"},Y={class:"font-medium text-gray-900 dark:text-white"},ee={key:2,class:"card"},ne={class:"flex items-center gap-3 mb-6 pb-4 border-b dark:border-gray-700"},te={class:"text-3xl"},ie={class:"text-xl font-semibold text-gray-900 dark:text-white"},re=["innerHTML"],le=K({__name:"HelpView",setup(ae){const l=f(""),u=f(null),o=f(null),g=[{id:"getting-started",name:"Erste Schritte",icon:"🚀",description:"Grundlagen und Einrichtung",articles:[{id:"overview",title:"Übersicht",icon:"📋",content:`
## Was ist SeCu?
SeCu ist eine modulare Mitarbeiterverwaltung speziell für Sicherheitsunternehmen. Die Software hilft Ihnen bei:

View File

@@ -1 +1 @@
import{d as h,u as q,r as a,c,a as e,t as g,w as B,b as r,v as n,F as N,e as k,f as R,g as U,o as f}from"./index-DFiGttzi.js";const A={class:"min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8"},M={class:"max-w-md w-full space-y-8"},C={class:"card"},E={class:"text-xl font-semibold text-gray-900 dark:text-white mb-6"},D={class:"grid grid-cols-2 gap-4"},F=["placeholder"],O={key:1,class:"p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm"},T=["disabled"],j={class:"mt-4 text-center"},L=h({__name:"LoginView",setup(z){const w=R(),V=U(),m=q(),o=a(""),u=a(""),i=a(m.orgSlug||""),p=a(""),v=a(!1),s=a(!1),x=a(""),y=a(""),b=a("");async function S(){p.value="",v.value=!0;try{s.value?(await m.register({email:o.value,password:u.value,first_name:x.value,last_name:y.value,phone:b.value,org_slug:i.value}),await m.login(o.value,u.value,i.value)):await m.login(o.value,u.value,i.value);const d=V.query.redirect||"/";w.push(d)}catch(d){p.value=d instanceof Error?d.message:"Anmeldung fehlgeschlagen"}finally{v.value=!1}}return(d,t)=>(f(),c("div",A,[e("div",M,[t[13]||(t[13]=e("div",{class:"text-center"},[e("h1",{class:"text-4xl font-bold text-primary-600"},"🔐 SeCu"),e("p",{class:"mt-2 text-gray-600 dark:text-gray-400"},"Mitarbeiterverwaltung")],-1)),e("div",C,[e("h2",E,g(s.value?"Registrieren":"Anmelden"),1),e("form",{onSubmit:B(S,["prevent"]),class:"space-y-4"},[e("div",null,[t[7]||(t[7]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," Organisation ",-1)),r(e("input",{"onUpdate:modelValue":t[0]||(t[0]=l=>i.value=l),type:"text",required:"",class:"input",placeholder:"z.B. demo"},null,512),[[n,i.value]])]),e("div",null,[t[8]||(t[8]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," E-Mail ",-1)),r(e("input",{"onUpdate:modelValue":t[1]||(t[1]=l=>o.value=l),type:"email",required:"",class:"input",placeholder:"name@firma.de"},null,512),[[n,o.value]])]),s.value?(f(),c(N,{key:0},[e("div",D,[e("div",null,[t[9]||(t[9]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," Vorname ",-1)),r(e("input",{"onUpdate:modelValue":t[2]||(t[2]=l=>x.value=l),type:"text",required:"",class:"input"},null,512),[[n,x.value]])]),e("div",null,[t[10]||(t[10]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," Nachname ",-1)),r(e("input",{"onUpdate:modelValue":t[3]||(t[3]=l=>y.value=l),type:"text",required:"",class:"input"},null,512),[[n,y.value]])])]),e("div",null,[t[11]||(t[11]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," Telefon ",-1)),r(e("input",{"onUpdate:modelValue":t[4]||(t[4]=l=>b.value=l),type:"tel",class:"input",placeholder:"Optional"},null,512),[[n,b.value]])])],64)):k("",!0),e("div",null,[t[12]||(t[12]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," Passwort ",-1)),r(e("input",{"onUpdate:modelValue":t[5]||(t[5]=l=>u.value=l),type:"password",required:"",class:"input",placeholder:s.value?"Mindestens 8 Zeichen":""},null,8,F),[[n,u.value]])]),p.value?(f(),c("div",O,g(p.value),1)):k("",!0),e("button",{type:"submit",disabled:v.value,class:"btn btn-primary w-full"},g(v.value?"Bitte warten...":s.value?"Registrieren":"Anmelden"),9,T)],32),e("div",j,[e("button",{type:"button",class:"text-sm text-primary-600 hover:text-primary-700",onClick:t[6]||(t[6]=l=>s.value=!s.value)},g(s.value?"Bereits registriert? Anmelden":"Noch kein Konto? Registrieren"),1)])])])]))}});export{L as default};
import{d as h,u as q,r as a,c,a as e,t as g,w as B,b as r,v as n,F as N,e as k,f as R,g as U,o as f}from"./index-OvQoqblD.js";const A={class:"min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8"},M={class:"max-w-md w-full space-y-8"},C={class:"card"},E={class:"text-xl font-semibold text-gray-900 dark:text-white mb-6"},D={class:"grid grid-cols-2 gap-4"},F=["placeholder"],O={key:1,class:"p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm"},T=["disabled"],j={class:"mt-4 text-center"},L=h({__name:"LoginView",setup(z){const w=R(),V=U(),m=q(),o=a(""),u=a(""),i=a(m.orgSlug||""),p=a(""),v=a(!1),s=a(!1),x=a(""),y=a(""),b=a("");async function S(){p.value="",v.value=!0;try{s.value?(await m.register({email:o.value,password:u.value,first_name:x.value,last_name:y.value,phone:b.value,org_slug:i.value}),await m.login(o.value,u.value,i.value)):await m.login(o.value,u.value,i.value);const d=V.query.redirect||"/";w.push(d)}catch(d){p.value=d instanceof Error?d.message:"Anmeldung fehlgeschlagen"}finally{v.value=!1}}return(d,t)=>(f(),c("div",A,[e("div",M,[t[13]||(t[13]=e("div",{class:"text-center"},[e("h1",{class:"text-4xl font-bold text-primary-600"},"🔐 SeCu"),e("p",{class:"mt-2 text-gray-600 dark:text-gray-400"},"Mitarbeiterverwaltung")],-1)),e("div",C,[e("h2",E,g(s.value?"Registrieren":"Anmelden"),1),e("form",{onSubmit:B(S,["prevent"]),class:"space-y-4"},[e("div",null,[t[7]||(t[7]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," Organisation ",-1)),r(e("input",{"onUpdate:modelValue":t[0]||(t[0]=l=>i.value=l),type:"text",required:"",class:"input",placeholder:"z.B. demo"},null,512),[[n,i.value]])]),e("div",null,[t[8]||(t[8]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," E-Mail ",-1)),r(e("input",{"onUpdate:modelValue":t[1]||(t[1]=l=>o.value=l),type:"email",required:"",class:"input",placeholder:"name@firma.de"},null,512),[[n,o.value]])]),s.value?(f(),c(N,{key:0},[e("div",D,[e("div",null,[t[9]||(t[9]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," Vorname ",-1)),r(e("input",{"onUpdate:modelValue":t[2]||(t[2]=l=>x.value=l),type:"text",required:"",class:"input"},null,512),[[n,x.value]])]),e("div",null,[t[10]||(t[10]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," Nachname ",-1)),r(e("input",{"onUpdate:modelValue":t[3]||(t[3]=l=>y.value=l),type:"text",required:"",class:"input"},null,512),[[n,y.value]])])]),e("div",null,[t[11]||(t[11]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," Telefon ",-1)),r(e("input",{"onUpdate:modelValue":t[4]||(t[4]=l=>b.value=l),type:"tel",class:"input",placeholder:"Optional"},null,512),[[n,b.value]])])],64)):k("",!0),e("div",null,[t[12]||(t[12]=e("label",{class:"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"}," Passwort ",-1)),r(e("input",{"onUpdate:modelValue":t[5]||(t[5]=l=>u.value=l),type:"password",required:"",class:"input",placeholder:s.value?"Mindestens 8 Zeichen":""},null,8,F),[[n,u.value]])]),p.value?(f(),c("div",O,g(p.value),1)):k("",!0),e("button",{type:"submit",disabled:v.value,class:"btn btn-primary w-full"},g(v.value?"Bitte warten...":s.value?"Registrieren":"Anmelden"),9,T)],32),e("div",j,[e("button",{type:"button",class:"text-sm text-primary-600 hover:text-primary-700",onClick:t[6]||(t[6]=l=>s.value=!s.value)},g(s.value?"Bereits registriert? Anmelden":"Noch kein Konto? Registrieren"),1)])])])]))}});export{L as default};

View File

@@ -1 +1 @@
import{d as k,q as h,m as d,c as l,a as t,t as r,e as u,F as w,x as M,r as c,o as n,n as f}from"./index-DFiGttzi.js";const S={class:"space-y-6"},C={key:0,class:"card bg-gradient-to-r from-primary-500 to-primary-700 text-white"},B={class:"grid grid-cols-2 md:grid-cols-4 gap-4"},V={class:"text-2xl font-bold"},z={class:"text-2xl font-bold"},A={class:"text-2xl font-bold"},F={class:"text-2xl font-bold"},D={class:"card"},E={key:0,class:"text-center py-8 text-gray-500"},L={key:1,class:"space-y-4"},N={class:"flex-1"},$={class:"flex items-center gap-2"},j={class:"font-medium text-gray-900 dark:text-white"},q={key:0,class:"badge badge-primary"},H={key:0,class:"text-sm text-gray-500 mt-1"},P=["disabled","onClick"],K=k({__name:"ModulesView",setup(G){const g=c([]),i=c(!0),o=c(null);h(async()=>{await Promise.all([b(),v()])});async function b(){i.value=!0;try{const s=await d.get("/modules/org");g.value=s.data.modules}catch(s){console.error(s)}finally{i.value=!1}}async function v(){try{const s=await d.get("/modules/developer/status");o.value=s.data}catch{console.log("Dev status not available")}}async function _(s){if(!s.is_core)try{await d.post(`/modules/${s.id}/toggle`,{enabled:!s.enabled}),s.enabled=!s.enabled}catch(e){alert(e instanceof Error?e.message:"Fehler")}}return(s,e)=>{var m,p,y,x;return n(),l("div",S,[e[6]||(e[6]=t("h1",{class:"text-2xl font-bold text-gray-900 dark:text-white"},"⚙️ Module",-1)),o.value?(n(),l("div",C,[e[4]||(e[4]=t("h2",{class:"text-lg font-semibold mb-4"},"System Status",-1)),t("div",B,[t("div",null,[e[0]||(e[0]=t("p",{class:"text-primary-100 text-sm"},"Benutzer",-1)),t("p",V,r(((m=o.value.stats)==null?void 0:m.user_count)||0),1)]),t("div",null,[e[1]||(e[1]=t("p",{class:"text-primary-100 text-sm"},"Aufträge",-1)),t("p",z,r(((p=o.value.stats)==null?void 0:p.order_count)||0),1)]),t("div",null,[e[2]||(e[2]=t("p",{class:"text-primary-100 text-sm"},"Stundenzettel",-1)),t("p",A,r(((y=o.value.stats)==null?void 0:y.timesheet_count)||0),1)]),t("div",null,[e[3]||(e[3]=t("p",{class:"text-primary-100 text-sm"},"Aktive Module",-1)),t("p",F,r(((x=o.value.stats)==null?void 0:x.enabled_modules)||0),1)])])])):u("",!0),t("div",D,[e[5]||(e[5]=t("h2",{class:"text-lg font-semibold mb-4"},"Verfügbare Module",-1)),i.value?(n(),l("div",E,"Lädt...")):(n(),l("div",L,[(n(!0),l(w,null,M(g.value,a=>(n(),l("div",{key:a.id,class:"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"},[t("div",N,[t("div",$,[t("h3",j,r(a.display_name),1),a.is_core?(n(),l("span",q,"Core")):u("",!0)]),a.description?(n(),l("p",H,r(a.description),1)):u("",!0)]),t("button",{disabled:a.is_core,class:f(["relative inline-flex h-6 w-11 items-center rounded-full transition-colors",a.enabled?"bg-primary-600":"bg-gray-200 dark:bg-gray-600",a.is_core?"opacity-50 cursor-not-allowed":"cursor-pointer"]),onClick:I=>_(a)},[t("span",{class:f(["inline-block h-4 w-4 transform rounded-full bg-white transition-transform",a.enabled?"translate-x-6":"translate-x-1"])},null,2)],10,P)]))),128))]))]),e[7]||(e[7]=t("div",{class:"card"},[t("h2",{class:"text-lg font-semibold mb-2"},"Hinweis"),t("p",{class:"text-gray-500 text-sm"}," Core-Module (Basis-System, Auftragsverwaltung) können nicht deaktiviert werden. Änderungen an Modulen werden sofort wirksam. ")],-1))])}}});export{K as default};
import{d as k,q as h,m as d,c as l,a as t,t as r,e as u,F as w,x as M,r as c,o as n,n as f}from"./index-OvQoqblD.js";const S={class:"space-y-6"},C={key:0,class:"card bg-gradient-to-r from-primary-500 to-primary-700 text-white"},B={class:"grid grid-cols-2 md:grid-cols-4 gap-4"},V={class:"text-2xl font-bold"},z={class:"text-2xl font-bold"},A={class:"text-2xl font-bold"},F={class:"text-2xl font-bold"},D={class:"card"},E={key:0,class:"text-center py-8 text-gray-500"},L={key:1,class:"space-y-4"},N={class:"flex-1"},$={class:"flex items-center gap-2"},j={class:"font-medium text-gray-900 dark:text-white"},q={key:0,class:"badge badge-primary"},H={key:0,class:"text-sm text-gray-500 mt-1"},P=["disabled","onClick"],K=k({__name:"ModulesView",setup(G){const g=c([]),i=c(!0),o=c(null);h(async()=>{await Promise.all([b(),v()])});async function b(){i.value=!0;try{const s=await d.get("/modules/org");g.value=s.data.modules}catch(s){console.error(s)}finally{i.value=!1}}async function v(){try{const s=await d.get("/modules/developer/status");o.value=s.data}catch{console.log("Dev status not available")}}async function _(s){if(!s.is_core)try{await d.post(`/modules/${s.id}/toggle`,{enabled:!s.enabled}),s.enabled=!s.enabled}catch(e){alert(e instanceof Error?e.message:"Fehler")}}return(s,e)=>{var m,p,y,x;return n(),l("div",S,[e[6]||(e[6]=t("h1",{class:"text-2xl font-bold text-gray-900 dark:text-white"},"⚙️ Module",-1)),o.value?(n(),l("div",C,[e[4]||(e[4]=t("h2",{class:"text-lg font-semibold mb-4"},"System Status",-1)),t("div",B,[t("div",null,[e[0]||(e[0]=t("p",{class:"text-primary-100 text-sm"},"Benutzer",-1)),t("p",V,r(((m=o.value.stats)==null?void 0:m.user_count)||0),1)]),t("div",null,[e[1]||(e[1]=t("p",{class:"text-primary-100 text-sm"},"Aufträge",-1)),t("p",z,r(((p=o.value.stats)==null?void 0:p.order_count)||0),1)]),t("div",null,[e[2]||(e[2]=t("p",{class:"text-primary-100 text-sm"},"Stundenzettel",-1)),t("p",A,r(((y=o.value.stats)==null?void 0:y.timesheet_count)||0),1)]),t("div",null,[e[3]||(e[3]=t("p",{class:"text-primary-100 text-sm"},"Aktive Module",-1)),t("p",F,r(((x=o.value.stats)==null?void 0:x.enabled_modules)||0),1)])])])):u("",!0),t("div",D,[e[5]||(e[5]=t("h2",{class:"text-lg font-semibold mb-4"},"Verfügbare Module",-1)),i.value?(n(),l("div",E,"Lädt...")):(n(),l("div",L,[(n(!0),l(w,null,M(g.value,a=>(n(),l("div",{key:a.id,class:"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"},[t("div",N,[t("div",$,[t("h3",j,r(a.display_name),1),a.is_core?(n(),l("span",q,"Core")):u("",!0)]),a.description?(n(),l("p",H,r(a.description),1)):u("",!0)]),t("button",{disabled:a.is_core,class:f(["relative inline-flex h-6 w-11 items-center rounded-full transition-colors",a.enabled?"bg-primary-600":"bg-gray-200 dark:bg-gray-600",a.is_core?"opacity-50 cursor-not-allowed":"cursor-pointer"]),onClick:I=>_(a)},[t("span",{class:f(["inline-block h-4 w-4 transform rounded-full bg-white transition-transform",a.enabled?"translate-x-6":"translate-x-1"])},null,2)],10,P)]))),128))]))]),e[7]||(e[7]=t("div",{class:"card"},[t("h2",{class:"text-lg font-semibold mb-2"},"Hinweis"),t("p",{class:"text-gray-500 text-sm"}," Core-Module (Basis-System, Auftragsverwaltung) können nicht deaktiviert werden. Änderungen an Modulen werden sofort wirksam. ")],-1))])}}});export{K as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{d as A,u as $,q as B,m as p,c as r,i as S,j as V,a as e,t as o,e as l,n as _,y as k,F as h,x as E,g as M,f as N,r as y,l as D,o as i,p as F}from"./index-DFiGttzi.js";const L={key:0,class:"text-center py-12 text-gray-500"},j={key:1,class:"space-y-6"},z={class:"card"},O={class:"flex items-start justify-between"},q={class:"flex items-center gap-3"},I={class:"text-gray-500"},R={class:"text-2xl font-bold text-gray-900 dark:text-white"},Z={key:0,class:"mt-2 text-gray-600 dark:text-gray-400"},H={class:"grid grid-cols-2 md:grid-cols-4 gap-4 mt-6"},K={key:0},T={class:"font-medium"},G={key:1},J={class:"font-medium"},P={key:2},Q={class:"font-medium"},U={class:"font-medium"},W={key:0,class:"mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"},X={class:"flex flex-wrap gap-2"},Y={class:"card"},tt={key:0,class:"text-center py-4 text-gray-500"},et={key:1,class:"space-y-3"},st={class:"font-medium text-gray-900 dark:text-white"},at={class:"text-sm text-gray-500"},nt={class:"flex items-center gap-2"},rt={key:0,class:"card"},ot={class:"text-gray-600 dark:text-gray-400 whitespace-pre-wrap"},dt=A({__name:"OrderDetailView",setup(it){const c=M(),w=N(),m=$(),s=y(null),d=y([]),v=y(!0);B(async()=>{try{const n=await p.get(`/orders/${c.params.id}`);s.value=n.data.order,d.value=n.data.assignments}catch(n){console.error(n),w.push("/orders")}finally{v.value=!1}});async function u(n){try{await p.put(`/orders/${c.params.id}`,{status:n}),s.value.status=n}catch(t){alert(t instanceof Error?t.message:"Fehler")}}async function x(n){try{await p.put(`/orders/${c.params.id}/assignment`,{status:n?"confirmed":"declined"});const t=d.value.find(g=>{var a;return g.user_id===((a=m.user)==null?void 0:a.id)});t&&(t.status=n?"confirmed":"declined")}catch(t){alert(t instanceof Error?t.message:"Fehler")}}function b(n){return{draft:"Entwurf",published:"Veröffentlicht",in_progress:"In Bearbeitung",completed:"Abgeschlossen",cancelled:"Abgesagt",pending:"Ausstehend",confirmed:"Bestätigt",declined:"Abgelehnt"}[n]||n}return(n,t)=>{const g=D("router-link");return v.value?(i(),r("div",L,"Lädt...")):s.value?(i(),r("div",j,[S(g,{to:"/orders",class:"text-primary-600 hover:text-primary-700 text-sm"},{default:V(()=>[...t[6]||(t[6]=[F(" ← Zurück zu Aufträge ",-1)])]),_:1}),e("div",z,[e("div",O,[e("div",null,[e("div",q,[e("span",I,"#"+o(s.value.number),1),e("h1",R,o(s.value.title),1)]),s.value.description?(i(),r("p",Z,o(s.value.description),1)):l("",!0)]),e("span",{class:_(["badge",s.value.status==="completed"?"badge-success":"badge-primary"])},o(b(s.value.status)),3)]),e("div",H,[s.value.location?(i(),r("div",K,[t[7]||(t[7]=e("p",{class:"text-sm text-gray-500"},"Ort",-1)),e("p",T,"📍 "+o(s.value.location),1)])):l("",!0),s.value.start_time?(i(),r("div",G,[t[8]||(t[8]=e("p",{class:"text-sm text-gray-500"},"Start",-1)),e("p",J,o(new Date(s.value.start_time).toLocaleString("de-DE")),1)])):l("",!0),s.value.client_name?(i(),r("div",P,[t[9]||(t[9]=e("p",{class:"text-sm text-gray-500"},"Kunde",-1)),e("p",Q,o(s.value.client_name),1)])):l("",!0),e("div",null,[t[10]||(t[10]=e("p",{class:"text-sm text-gray-500"},"Benötigte MA",-1)),e("p",U,o(d.value.length)+"/"+o(s.value.required_staff),1)])]),k(m).canManageOrders?(i(),r("div",W,[t[11]||(t[11]=e("p",{class:"text-sm text-gray-500 mb-2"},"Status ändern:",-1)),e("div",X,[e("button",{class:"btn btn-secondary text-sm",onClick:t[0]||(t[0]=a=>u("draft"))},"Entwurf"),e("button",{class:"btn btn-primary text-sm",onClick:t[1]||(t[1]=a=>u("published"))},"Veröffentlichen"),e("button",{class:"btn btn-warning text-sm",onClick:t[2]||(t[2]=a=>u("in_progress"))},"In Bearbeitung"),e("button",{class:"btn btn-success text-sm",onClick:t[3]||(t[3]=a=>u("completed"))},"Abschließen")])])):l("",!0)]),e("div",Y,[t[12]||(t[12]=e("h2",{class:"text-lg font-semibold text-gray-900 dark:text-white mb-4"}," 👥 Zugewiesene Mitarbeiter ",-1)),d.value.length===0?(i(),r("div",tt," Noch keine Mitarbeiter zugewiesen ")):(i(),r("div",et,[(i(!0),r(h,null,E(d.value,a=>{var f;return i(),r("div",{key:a.id,class:"flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"},[e("div",null,[e("p",st,o(a.user_name),1),e("p",at,o(a.user_phone),1)]),e("div",nt,[e("span",{class:_(["badge",a.status==="confirmed"?"badge-success":a.status==="declined"?"badge-danger":"badge-warning"])},o(b(a.status)),3),a.user_id===((f=k(m).user)==null?void 0:f.id)&&a.status==="pending"?(i(),r(h,{key:0},[e("button",{class:"btn btn-success text-sm",onClick:t[4]||(t[4]=C=>x(!0))},"✓"),e("button",{class:"btn btn-danger text-sm",onClick:t[5]||(t[5]=C=>x(!1))},"✗")],64)):l("",!0)])])}),128))]))]),s.value.special_instructions?(i(),r("div",rt,[t[13]||(t[13]=e("h2",{class:"text-lg font-semibold text-gray-900 dark:text-white mb-2"}," 📝 Besondere Hinweise ",-1)),e("p",ot,o(s.value.special_instructions),1)])):l("",!0)])):l("",!0)}}});export{dt as default};
import{d as A,u as $,q as B,m as p,c as r,i as S,j as V,a as e,t as o,e as l,n as _,y as k,F as h,x as E,g as M,f as N,r as y,l as D,o as i,p as F}from"./index-OvQoqblD.js";const L={key:0,class:"text-center py-12 text-gray-500"},j={key:1,class:"space-y-6"},z={class:"card"},O={class:"flex items-start justify-between"},q={class:"flex items-center gap-3"},I={class:"text-gray-500"},R={class:"text-2xl font-bold text-gray-900 dark:text-white"},Z={key:0,class:"mt-2 text-gray-600 dark:text-gray-400"},H={class:"grid grid-cols-2 md:grid-cols-4 gap-4 mt-6"},K={key:0},T={class:"font-medium"},G={key:1},J={class:"font-medium"},P={key:2},Q={class:"font-medium"},U={class:"font-medium"},W={key:0,class:"mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"},X={class:"flex flex-wrap gap-2"},Y={class:"card"},tt={key:0,class:"text-center py-4 text-gray-500"},et={key:1,class:"space-y-3"},st={class:"font-medium text-gray-900 dark:text-white"},at={class:"text-sm text-gray-500"},nt={class:"flex items-center gap-2"},rt={key:0,class:"card"},ot={class:"text-gray-600 dark:text-gray-400 whitespace-pre-wrap"},dt=A({__name:"OrderDetailView",setup(it){const c=M(),w=N(),m=$(),s=y(null),d=y([]),v=y(!0);B(async()=>{try{const n=await p.get(`/orders/${c.params.id}`);s.value=n.data.order,d.value=n.data.assignments}catch(n){console.error(n),w.push("/orders")}finally{v.value=!1}});async function u(n){try{await p.put(`/orders/${c.params.id}`,{status:n}),s.value.status=n}catch(t){alert(t instanceof Error?t.message:"Fehler")}}async function x(n){try{await p.put(`/orders/${c.params.id}/assignment`,{status:n?"confirmed":"declined"});const t=d.value.find(g=>{var a;return g.user_id===((a=m.user)==null?void 0:a.id)});t&&(t.status=n?"confirmed":"declined")}catch(t){alert(t instanceof Error?t.message:"Fehler")}}function b(n){return{draft:"Entwurf",published:"Veröffentlicht",in_progress:"In Bearbeitung",completed:"Abgeschlossen",cancelled:"Abgesagt",pending:"Ausstehend",confirmed:"Bestätigt",declined:"Abgelehnt"}[n]||n}return(n,t)=>{const g=D("router-link");return v.value?(i(),r("div",L,"Lädt...")):s.value?(i(),r("div",j,[S(g,{to:"/orders",class:"text-primary-600 hover:text-primary-700 text-sm"},{default:V(()=>[...t[6]||(t[6]=[F(" ← Zurück zu Aufträge ",-1)])]),_:1}),e("div",z,[e("div",O,[e("div",null,[e("div",q,[e("span",I,"#"+o(s.value.number),1),e("h1",R,o(s.value.title),1)]),s.value.description?(i(),r("p",Z,o(s.value.description),1)):l("",!0)]),e("span",{class:_(["badge",s.value.status==="completed"?"badge-success":"badge-primary"])},o(b(s.value.status)),3)]),e("div",H,[s.value.location?(i(),r("div",K,[t[7]||(t[7]=e("p",{class:"text-sm text-gray-500"},"Ort",-1)),e("p",T,"📍 "+o(s.value.location),1)])):l("",!0),s.value.start_time?(i(),r("div",G,[t[8]||(t[8]=e("p",{class:"text-sm text-gray-500"},"Start",-1)),e("p",J,o(new Date(s.value.start_time).toLocaleString("de-DE")),1)])):l("",!0),s.value.client_name?(i(),r("div",P,[t[9]||(t[9]=e("p",{class:"text-sm text-gray-500"},"Kunde",-1)),e("p",Q,o(s.value.client_name),1)])):l("",!0),e("div",null,[t[10]||(t[10]=e("p",{class:"text-sm text-gray-500"},"Benötigte MA",-1)),e("p",U,o(d.value.length)+"/"+o(s.value.required_staff),1)])]),k(m).canManageOrders?(i(),r("div",W,[t[11]||(t[11]=e("p",{class:"text-sm text-gray-500 mb-2"},"Status ändern:",-1)),e("div",X,[e("button",{class:"btn btn-secondary text-sm",onClick:t[0]||(t[0]=a=>u("draft"))},"Entwurf"),e("button",{class:"btn btn-primary text-sm",onClick:t[1]||(t[1]=a=>u("published"))},"Veröffentlichen"),e("button",{class:"btn btn-warning text-sm",onClick:t[2]||(t[2]=a=>u("in_progress"))},"In Bearbeitung"),e("button",{class:"btn btn-success text-sm",onClick:t[3]||(t[3]=a=>u("completed"))},"Abschließen")])])):l("",!0)]),e("div",Y,[t[12]||(t[12]=e("h2",{class:"text-lg font-semibold text-gray-900 dark:text-white mb-4"}," 👥 Zugewiesene Mitarbeiter ",-1)),d.value.length===0?(i(),r("div",tt," Noch keine Mitarbeiter zugewiesen ")):(i(),r("div",et,[(i(!0),r(h,null,E(d.value,a=>{var f;return i(),r("div",{key:a.id,class:"flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"},[e("div",null,[e("p",st,o(a.user_name),1),e("p",at,o(a.user_phone),1)]),e("div",nt,[e("span",{class:_(["badge",a.status==="confirmed"?"badge-success":a.status==="declined"?"badge-danger":"badge-warning"])},o(b(a.status)),3),a.user_id===((f=k(m).user)==null?void 0:f.id)&&a.status==="pending"?(i(),r(h,{key:0},[e("button",{class:"btn btn-success text-sm",onClick:t[4]||(t[4]=C=>x(!0))},"✓"),e("button",{class:"btn btn-danger text-sm",onClick:t[5]||(t[5]=C=>x(!1))},"✗")],64)):l("",!0)])])}),128))]))]),s.value.special_instructions?(i(),r("div",rt,[t[13]||(t[13]=e("h2",{class:"text-lg font-semibold text-gray-900 dark:text-white mb-2"}," 📝 Besondere Hinweise ",-1)),e("p",ot,o(s.value.special_instructions),1)])):l("",!0)])):l("",!0)}}});export{dt as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/SettingsView-Bkdz88hj.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{d as k,u as h,c as p,a as e,t as a,y as b,w as S,b as c,v as f,e as y,r as o,o as w,m as V}from"./index-DFiGttzi.js";const N={class:"space-y-6"},E={class:"card"},_={class:"grid grid-cols-2 gap-4"},q={class:"font-medium"},B={class:"font-medium"},M={class:"font-medium capitalize"},U={class:"card"},A={key:0,class:"text-red-600 text-sm"},C={key:1,class:"text-green-600 text-sm"},D=["disabled"],R=k({__name:"SettingsView",setup(z){const m=h(),r=o(""),t=o(""),i=o(""),u=o(!1),d=o(""),l=o("");async function P(){if(t.value!==i.value){l.value="Passwörter stimmen nicht überein";return}if(t.value.length<8){l.value="Passwort muss mindestens 8 Zeichen haben";return}u.value=!0,l.value="",d.value="";try{await V.post("/auth/change-password",{currentPassword:r.value,newPassword:t.value}),d.value="Passwort erfolgreich geändert",r.value="",t.value="",i.value=""}catch(v){l.value=v instanceof Error?v.message:"Fehler beim Ändern"}finally{u.value=!1}}return(v,s)=>{var x,g;return w(),p("div",N,[s[11]||(s[11]=e("h1",{class:"text-2xl font-bold text-gray-900 dark:text-white"},"🔧 Einstellungen",-1)),e("div",E,[s[6]||(s[6]=e("h2",{class:"text-lg font-semibold mb-4"},"Profil",-1)),e("div",_,[e("div",null,[s[3]||(s[3]=e("label",{class:"block text-sm text-gray-500"},"Name",-1)),e("p",q,a(b(m).fullName),1)]),e("div",null,[s[4]||(s[4]=e("label",{class:"block text-sm text-gray-500"},"E-Mail",-1)),e("p",B,a((x=b(m).user)==null?void 0:x.email),1)]),e("div",null,[s[5]||(s[5]=e("label",{class:"block text-sm text-gray-500"},"Rolle",-1)),e("p",M,a((g=b(m).user)==null?void 0:g.role),1)])])]),e("div",U,[s[10]||(s[10]=e("h2",{class:"text-lg font-semibold mb-4"},"Passwort ändern",-1)),e("form",{onSubmit:S(P,["prevent"]),class:"space-y-4 max-w-md"},[e("div",null,[s[7]||(s[7]=e("label",{class:"block text-sm font-medium mb-1"},"Aktuelles Passwort",-1)),c(e("input",{"onUpdate:modelValue":s[0]||(s[0]=n=>r.value=n),type:"password",required:"",class:"input"},null,512),[[f,r.value]])]),e("div",null,[s[8]||(s[8]=e("label",{class:"block text-sm font-medium mb-1"},"Neues Passwort",-1)),c(e("input",{"onUpdate:modelValue":s[1]||(s[1]=n=>t.value=n),type:"password",required:"",class:"input"},null,512),[[f,t.value]])]),e("div",null,[s[9]||(s[9]=e("label",{class:"block text-sm font-medium mb-1"},"Passwort bestätigen",-1)),c(e("input",{"onUpdate:modelValue":s[2]||(s[2]=n=>i.value=n),type:"password",required:"",class:"input"},null,512),[[f,i.value]])]),l.value?(w(),p("div",A,a(l.value),1)):y("",!0),d.value?(w(),p("div",C,a(d.value),1)):y("",!0),e("button",{type:"submit",disabled:u.value,class:"btn btn-primary"},a(u.value?"Speichern...":"Passwort ändern"),9,D)],32)])])}}});export{R as default};

View File

@@ -1 +1 @@
import{d as A,u as M,q as T,m,c as a,a as s,F as y,x as w,w as D,b as p,v as _,s as E,e as i,r as d,o as r,t as l,p as U,y as h,n as z}from"./index-DFiGttzi.js";const N={class:"space-y-6"},$={class:"flex items-center justify-between"},B={class:"card"},F={key:0,class:"text-center py-8 text-gray-500"},L={key:1,class:"text-center py-8 text-gray-500"},q={key:2,class:"space-y-3"},K={class:"font-medium"},G={class:"text-sm text-gray-500"},O={key:0},P={key:0,class:"text-sm text-gray-500"},H={key:1,class:"text-sm text-gray-500"},I={class:"flex items-center gap-2"},J=["onClick"],Q=["onClick"],R={key:0,class:"fixed inset-0 z-50 flex items-center justify-center bg-black/50"},W={class:"card w-full max-w-md m-4"},X={class:"grid grid-cols-2 gap-4"},Y=["value"],Z={class:"flex justify-end gap-3 pt-4"},se=A({__name:"TimesheetsView",setup(ee){const f=M(),v=d([]),b=d(!0),u=d(!1),o=d({work_date:"",start_time:"",end_time:"",order_id:""}),x=d([]);T(async()=>{await Promise.all([g(),S()])});async function g(){b.value=!0;try{const n=await m.get("/timesheets");v.value=n.data.timesheets}catch(n){console.error(n)}finally{b.value=!1}}async function S(){try{const n=await m.get("/orders");x.value=n.data.orders}catch(n){console.error(n)}}async function j(){try{await m.post("/timesheets",o.value),u.value=!1,o.value={work_date:"",start_time:"",end_time:"",order_id:""},await g()}catch(n){alert(n instanceof Error?n.message:"Fehler")}}async function k(n,e){const t=e==="rejected"?prompt("Ablehnungsgrund:"):null;if(!(e==="rejected"&&!t))try{await m.post(`/timesheets/${n}/review`,{status:e,rejection_reason:t}),await g()}catch(c){alert(c instanceof Error?c.message:"Fehler")}}function C(n){return{pending:"badge-warning",approved:"badge-success",rejected:"badge-danger"}[n]||""}function V(n){return{pending:"Ausstehend",approved:"Genehmigt",rejected:"Abgelehnt"}[n]||n}return(n,e)=>(r(),a("div",N,[s("div",$,[e[6]||(e[6]=s("h1",{class:"text-2xl font-bold text-gray-900 dark:text-white"},"⏱️ Stundenzettel",-1)),s("button",{class:"btn btn-primary",onClick:e[0]||(e[0]=t=>u.value=!0)},"+ Neu")]),s("div",B,[b.value?(r(),a("div",F,"Lädt...")):v.value.length===0?(r(),a("div",L,"Keine Stundenzettel")):(r(),a("div",q,[(r(!0),a(y,null,w(v.value,t=>(r(),a("div",{key:t.id,class:"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"},[s("div",null,[s("p",K,l(new Date(t.work_date).toLocaleDateString("de-DE")),1),s("p",G,[U(l(t.start_time)+" - "+l(t.end_time)+" ",1),t.hours_worked?(r(),a("span",O,"("+l(t.hours_worked)+"h)",1)):i("",!0)]),t.order_title?(r(),a("p",P,"📋 "+l(t.order_title),1)):i("",!0),h(f).canManageUsers?(r(),a("p",H,"👤 "+l(t.user_name),1)):i("",!0)]),s("div",I,[s("span",{class:z(["badge",C(t.status)])},l(V(t.status)),3),h(f).canManageUsers&&t.status==="pending"?(r(),a(y,{key:0},[s("button",{class:"btn btn-success text-sm",onClick:c=>k(t.id,"approved")},"✓",8,J),s("button",{class:"btn btn-danger text-sm",onClick:c=>k(t.id,"rejected")},"✗",8,Q)],64)):i("",!0)])]))),128))]))]),u.value?(r(),a("div",R,[s("div",W,[e[13]||(e[13]=s("h2",{class:"text-xl font-semibold mb-6"},"Neuer Stundenzettel",-1)),s("form",{onSubmit:D(j,["prevent"]),class:"space-y-4"},[s("div",null,[e[7]||(e[7]=s("label",{class:"block text-sm font-medium mb-1"},"Datum *",-1)),p(s("input",{"onUpdate:modelValue":e[1]||(e[1]=t=>o.value.work_date=t),type:"date",required:"",class:"input"},null,512),[[_,o.value.work_date]])]),s("div",X,[s("div",null,[e[8]||(e[8]=s("label",{class:"block text-sm font-medium mb-1"},"Start",-1)),p(s("input",{"onUpdate:modelValue":e[2]||(e[2]=t=>o.value.start_time=t),type:"time",class:"input"},null,512),[[_,o.value.start_time]])]),s("div",null,[e[9]||(e[9]=s("label",{class:"block text-sm font-medium mb-1"},"Ende",-1)),p(s("input",{"onUpdate:modelValue":e[3]||(e[3]=t=>o.value.end_time=t),type:"time",class:"input"},null,512),[[_,o.value.end_time]])])]),s("div",null,[e[11]||(e[11]=s("label",{class:"block text-sm font-medium mb-1"},"Auftrag",-1)),p(s("select",{"onUpdate:modelValue":e[4]||(e[4]=t=>o.value.order_id=t),class:"input"},[e[10]||(e[10]=s("option",{value:""},"-- Kein Auftrag --",-1)),(r(!0),a(y,null,w(x.value,t=>(r(),a("option",{key:t.id,value:t.id},"#"+l(t.number)+" - "+l(t.title),9,Y))),128))],512),[[E,o.value.order_id]])]),s("div",Z,[s("button",{type:"button",class:"btn btn-secondary",onClick:e[5]||(e[5]=t=>u.value=!1)},"Abbrechen"),e[12]||(e[12]=s("button",{type:"submit",class:"btn btn-primary"},"Einreichen",-1))])],32)])])):i("",!0)]))}});export{se as default};
import{d as A,u as M,q as T,m,c as a,a as s,F as y,x as w,w as D,b as p,v as _,s as E,e as i,r as d,o as r,t as l,p as U,y as h,n as z}from"./index-OvQoqblD.js";const N={class:"space-y-6"},$={class:"flex items-center justify-between"},B={class:"card"},F={key:0,class:"text-center py-8 text-gray-500"},L={key:1,class:"text-center py-8 text-gray-500"},q={key:2,class:"space-y-3"},K={class:"font-medium"},G={class:"text-sm text-gray-500"},O={key:0},P={key:0,class:"text-sm text-gray-500"},H={key:1,class:"text-sm text-gray-500"},I={class:"flex items-center gap-2"},J=["onClick"],Q=["onClick"],R={key:0,class:"fixed inset-0 z-50 flex items-center justify-center bg-black/50"},W={class:"card w-full max-w-md m-4"},X={class:"grid grid-cols-2 gap-4"},Y=["value"],Z={class:"flex justify-end gap-3 pt-4"},se=A({__name:"TimesheetsView",setup(ee){const f=M(),v=d([]),b=d(!0),u=d(!1),o=d({work_date:"",start_time:"",end_time:"",order_id:""}),x=d([]);T(async()=>{await Promise.all([g(),S()])});async function g(){b.value=!0;try{const n=await m.get("/timesheets");v.value=n.data.timesheets}catch(n){console.error(n)}finally{b.value=!1}}async function S(){try{const n=await m.get("/orders");x.value=n.data.orders}catch(n){console.error(n)}}async function j(){try{await m.post("/timesheets",o.value),u.value=!1,o.value={work_date:"",start_time:"",end_time:"",order_id:""},await g()}catch(n){alert(n instanceof Error?n.message:"Fehler")}}async function k(n,e){const t=e==="rejected"?prompt("Ablehnungsgrund:"):null;if(!(e==="rejected"&&!t))try{await m.post(`/timesheets/${n}/review`,{status:e,rejection_reason:t}),await g()}catch(c){alert(c instanceof Error?c.message:"Fehler")}}function C(n){return{pending:"badge-warning",approved:"badge-success",rejected:"badge-danger"}[n]||""}function V(n){return{pending:"Ausstehend",approved:"Genehmigt",rejected:"Abgelehnt"}[n]||n}return(n,e)=>(r(),a("div",N,[s("div",$,[e[6]||(e[6]=s("h1",{class:"text-2xl font-bold text-gray-900 dark:text-white"},"⏱️ Stundenzettel",-1)),s("button",{class:"btn btn-primary",onClick:e[0]||(e[0]=t=>u.value=!0)},"+ Neu")]),s("div",B,[b.value?(r(),a("div",F,"Lädt...")):v.value.length===0?(r(),a("div",L,"Keine Stundenzettel")):(r(),a("div",q,[(r(!0),a(y,null,w(v.value,t=>(r(),a("div",{key:t.id,class:"flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"},[s("div",null,[s("p",K,l(new Date(t.work_date).toLocaleDateString("de-DE")),1),s("p",G,[U(l(t.start_time)+" - "+l(t.end_time)+" ",1),t.hours_worked?(r(),a("span",O,"("+l(t.hours_worked)+"h)",1)):i("",!0)]),t.order_title?(r(),a("p",P,"📋 "+l(t.order_title),1)):i("",!0),h(f).canManageUsers?(r(),a("p",H,"👤 "+l(t.user_name),1)):i("",!0)]),s("div",I,[s("span",{class:z(["badge",C(t.status)])},l(V(t.status)),3),h(f).canManageUsers&&t.status==="pending"?(r(),a(y,{key:0},[s("button",{class:"btn btn-success text-sm",onClick:c=>k(t.id,"approved")},"✓",8,J),s("button",{class:"btn btn-danger text-sm",onClick:c=>k(t.id,"rejected")},"✗",8,Q)],64)):i("",!0)])]))),128))]))]),u.value?(r(),a("div",R,[s("div",W,[e[13]||(e[13]=s("h2",{class:"text-xl font-semibold mb-6"},"Neuer Stundenzettel",-1)),s("form",{onSubmit:D(j,["prevent"]),class:"space-y-4"},[s("div",null,[e[7]||(e[7]=s("label",{class:"block text-sm font-medium mb-1"},"Datum *",-1)),p(s("input",{"onUpdate:modelValue":e[1]||(e[1]=t=>o.value.work_date=t),type:"date",required:"",class:"input"},null,512),[[_,o.value.work_date]])]),s("div",X,[s("div",null,[e[8]||(e[8]=s("label",{class:"block text-sm font-medium mb-1"},"Start",-1)),p(s("input",{"onUpdate:modelValue":e[2]||(e[2]=t=>o.value.start_time=t),type:"time",class:"input"},null,512),[[_,o.value.start_time]])]),s("div",null,[e[9]||(e[9]=s("label",{class:"block text-sm font-medium mb-1"},"Ende",-1)),p(s("input",{"onUpdate:modelValue":e[3]||(e[3]=t=>o.value.end_time=t),type:"time",class:"input"},null,512),[[_,o.value.end_time]])])]),s("div",null,[e[11]||(e[11]=s("label",{class:"block text-sm font-medium mb-1"},"Auftrag",-1)),p(s("select",{"onUpdate:modelValue":e[4]||(e[4]=t=>o.value.order_id=t),class:"input"},[e[10]||(e[10]=s("option",{value:""},"-- Kein Auftrag --",-1)),(r(!0),a(y,null,w(x.value,t=>(r(),a("option",{key:t.id,value:t.id},"#"+l(t.number)+" - "+l(t.title),9,Y))),128))],512),[[E,o.value.order_id]])]),s("div",Z,[s("button",{type:"button",class:"btn btn-secondary",onClick:e[5]||(e[5]=t=>u.value=!1)},"Abbrechen"),e[12]||(e[12]=s("button",{type:"submit",class:"btn btn-primary"},"Einreichen",-1))])],32)])])):i("",!0)]))}});export{se as default};

View File

@@ -1 +1 @@
import{d as C,u as U,q as V,m,c as n,a as e,F as E,x as N,w as S,b as o,v as d,y as x,s as q,e as y,r as b,o as i,t as r,n as k}from"./index-DFiGttzi.js";const A={class:"space-y-6"},D={class:"flex items-center justify-between"},$={class:"card"},B={key:0,class:"text-center py-8 text-gray-500"},F={key:1,class:"text-center py-8 text-gray-500"},R={key:2,class:"w-full"},j={class:"py-3 font-medium"},L={class:"py-3 text-gray-500"},z={class:"py-3"},T={class:"py-3"},I={class:"py-3 text-right"},K=["onClick"],P={key:0,class:"fixed inset-0 z-50 flex items-center justify-center bg-black/50"},G={class:"card w-full max-w-md m-4"},H={class:"grid grid-cols-2 gap-4"},J={key:0},O={class:"flex justify-end gap-3 pt-4"},Y=C({__name:"UsersView",setup(Q){const f=U(),p=b([]),c=b(!0),u=b(!1),l=b({email:"",password:"",first_name:"",last_name:"",phone:"",role:"mitarbeiter"});V(async()=>{await v()});async function v(){c.value=!0;try{const a=await m.get("/users");p.value=a.data.users}catch(a){console.error(a)}finally{c.value=!1}}async function w(){try{await m.post("/users",l.value),u.value=!1,l.value={email:"",password:"",first_name:"",last_name:"",phone:"",role:"mitarbeiter"},await v()}catch(a){alert(a instanceof Error?a.message:"Fehler beim Erstellen")}}async function _(a){try{a.active?await m.delete(`/users/${a.id}`):await m.put(`/users/${a.id}`,{active:!0}),await v()}catch(t){alert(t instanceof Error?t.message:"Fehler")}}function h(a){return{chef:"badge-danger",disponent:"badge-primary",mitarbeiter:"badge-success"}[a]||"badge-secondary"}function M(a){return{chef:"Chef",disponent:"Disponent",mitarbeiter:"Mitarbeiter"}[a]||a}return(a,t)=>(i(),n("div",A,[e("div",D,[t[8]||(t[8]=e("h1",{class:"text-2xl font-bold text-gray-900 dark:text-white"},"👥 Mitarbeiter",-1)),e("button",{class:"btn btn-primary",onClick:t[0]||(t[0]=s=>u.value=!0)},"+ Neu")]),e("div",$,[c.value?(i(),n("div",B,"Lädt...")):p.value.length===0?(i(),n("div",F,"Keine Mitarbeiter")):(i(),n("table",R,[t[9]||(t[9]=e("thead",null,[e("tr",{class:"text-left text-sm text-gray-500 border-b border-gray-200 dark:border-gray-700"},[e("th",{class:"pb-3"},"Name"),e("th",{class:"pb-3"},"E-Mail"),e("th",{class:"pb-3"},"Rolle"),e("th",{class:"pb-3"},"Status"),e("th",{class:"pb-3"})])],-1)),e("tbody",null,[(i(!0),n(E,null,N(p.value,s=>{var g;return i(),n("tr",{key:s.id,class:"border-b border-gray-100 dark:border-gray-800"},[e("td",j,r(s.first_name)+" "+r(s.last_name),1),e("td",L,r(s.email),1),e("td",z,[e("span",{class:k(["badge",h(s.role)])},r(M(s.role)),3)]),e("td",T,[e("span",{class:k(s.active?"text-green-600":"text-red-600")},r(s.active?"Aktiv":"Inaktiv"),3)]),e("td",I,[s.id!==((g=x(f).user)==null?void 0:g.id)&&s.role!=="chef"?(i(),n("button",{key:0,class:"text-sm text-gray-500 hover:text-red-600",onClick:W=>_(s)},r(s.active?"Deaktivieren":"Aktivieren"),9,K)):y("",!0)])])}),128))])]))]),u.value?(i(),n("div",P,[e("div",G,[t[18]||(t[18]=e("h2",{class:"text-xl font-semibold mb-6"},"Neuer Mitarbeiter",-1)),e("form",{onSubmit:S(w,["prevent"]),class:"space-y-4"},[e("div",H,[e("div",null,[t[10]||(t[10]=e("label",{class:"block text-sm font-medium mb-1"},"Vorname *",-1)),o(e("input",{"onUpdate:modelValue":t[1]||(t[1]=s=>l.value.first_name=s),type:"text",required:"",class:"input"},null,512),[[d,l.value.first_name]])]),e("div",null,[t[11]||(t[11]=e("label",{class:"block text-sm font-medium mb-1"},"Nachname *",-1)),o(e("input",{"onUpdate:modelValue":t[2]||(t[2]=s=>l.value.last_name=s),type:"text",required:"",class:"input"},null,512),[[d,l.value.last_name]])])]),e("div",null,[t[12]||(t[12]=e("label",{class:"block text-sm font-medium mb-1"},"E-Mail *",-1)),o(e("input",{"onUpdate:modelValue":t[3]||(t[3]=s=>l.value.email=s),type:"email",required:"",class:"input"},null,512),[[d,l.value.email]])]),e("div",null,[t[13]||(t[13]=e("label",{class:"block text-sm font-medium mb-1"},"Passwort *",-1)),o(e("input",{"onUpdate:modelValue":t[4]||(t[4]=s=>l.value.password=s),type:"password",required:"",class:"input"},null,512),[[d,l.value.password]])]),e("div",null,[t[14]||(t[14]=e("label",{class:"block text-sm font-medium mb-1"},"Telefon",-1)),o(e("input",{"onUpdate:modelValue":t[5]||(t[5]=s=>l.value.phone=s),type:"tel",class:"input"},null,512),[[d,l.value.phone]])]),x(f).isChef?(i(),n("div",J,[t[16]||(t[16]=e("label",{class:"block text-sm font-medium mb-1"},"Rolle",-1)),o(e("select",{"onUpdate:modelValue":t[6]||(t[6]=s=>l.value.role=s),class:"input"},[...t[15]||(t[15]=[e("option",{value:"mitarbeiter"},"Mitarbeiter",-1),e("option",{value:"disponent"},"Disponent",-1)])],512),[[q,l.value.role]])])):y("",!0),e("div",O,[e("button",{type:"button",class:"btn btn-secondary",onClick:t[7]||(t[7]=s=>u.value=!1)},"Abbrechen"),t[17]||(t[17]=e("button",{type:"submit",class:"btn btn-primary"},"Erstellen",-1))])],32)])])):y("",!0)]))}});export{Y as default};
import{d as C,u as U,q as V,m,c as n,a as e,F as E,x as N,w as S,b as o,v as d,y as x,s as q,e as y,r as b,o as i,t as r,n as k}from"./index-OvQoqblD.js";const A={class:"space-y-6"},D={class:"flex items-center justify-between"},$={class:"card"},B={key:0,class:"text-center py-8 text-gray-500"},F={key:1,class:"text-center py-8 text-gray-500"},R={key:2,class:"w-full"},j={class:"py-3 font-medium"},L={class:"py-3 text-gray-500"},z={class:"py-3"},T={class:"py-3"},I={class:"py-3 text-right"},K=["onClick"],P={key:0,class:"fixed inset-0 z-50 flex items-center justify-center bg-black/50"},G={class:"card w-full max-w-md m-4"},H={class:"grid grid-cols-2 gap-4"},J={key:0},O={class:"flex justify-end gap-3 pt-4"},Y=C({__name:"UsersView",setup(Q){const f=U(),p=b([]),c=b(!0),u=b(!1),l=b({email:"",password:"",first_name:"",last_name:"",phone:"",role:"mitarbeiter"});V(async()=>{await v()});async function v(){c.value=!0;try{const a=await m.get("/users");p.value=a.data.users}catch(a){console.error(a)}finally{c.value=!1}}async function w(){try{await m.post("/users",l.value),u.value=!1,l.value={email:"",password:"",first_name:"",last_name:"",phone:"",role:"mitarbeiter"},await v()}catch(a){alert(a instanceof Error?a.message:"Fehler beim Erstellen")}}async function _(a){try{a.active?await m.delete(`/users/${a.id}`):await m.put(`/users/${a.id}`,{active:!0}),await v()}catch(t){alert(t instanceof Error?t.message:"Fehler")}}function h(a){return{chef:"badge-danger",disponent:"badge-primary",mitarbeiter:"badge-success"}[a]||"badge-secondary"}function M(a){return{chef:"Chef",disponent:"Disponent",mitarbeiter:"Mitarbeiter"}[a]||a}return(a,t)=>(i(),n("div",A,[e("div",D,[t[8]||(t[8]=e("h1",{class:"text-2xl font-bold text-gray-900 dark:text-white"},"👥 Mitarbeiter",-1)),e("button",{class:"btn btn-primary",onClick:t[0]||(t[0]=s=>u.value=!0)},"+ Neu")]),e("div",$,[c.value?(i(),n("div",B,"Lädt...")):p.value.length===0?(i(),n("div",F,"Keine Mitarbeiter")):(i(),n("table",R,[t[9]||(t[9]=e("thead",null,[e("tr",{class:"text-left text-sm text-gray-500 border-b border-gray-200 dark:border-gray-700"},[e("th",{class:"pb-3"},"Name"),e("th",{class:"pb-3"},"E-Mail"),e("th",{class:"pb-3"},"Rolle"),e("th",{class:"pb-3"},"Status"),e("th",{class:"pb-3"})])],-1)),e("tbody",null,[(i(!0),n(E,null,N(p.value,s=>{var g;return i(),n("tr",{key:s.id,class:"border-b border-gray-100 dark:border-gray-800"},[e("td",j,r(s.first_name)+" "+r(s.last_name),1),e("td",L,r(s.email),1),e("td",z,[e("span",{class:k(["badge",h(s.role)])},r(M(s.role)),3)]),e("td",T,[e("span",{class:k(s.active?"text-green-600":"text-red-600")},r(s.active?"Aktiv":"Inaktiv"),3)]),e("td",I,[s.id!==((g=x(f).user)==null?void 0:g.id)&&s.role!=="chef"?(i(),n("button",{key:0,class:"text-sm text-gray-500 hover:text-red-600",onClick:W=>_(s)},r(s.active?"Deaktivieren":"Aktivieren"),9,K)):y("",!0)])])}),128))])]))]),u.value?(i(),n("div",P,[e("div",G,[t[18]||(t[18]=e("h2",{class:"text-xl font-semibold mb-6"},"Neuer Mitarbeiter",-1)),e("form",{onSubmit:S(w,["prevent"]),class:"space-y-4"},[e("div",H,[e("div",null,[t[10]||(t[10]=e("label",{class:"block text-sm font-medium mb-1"},"Vorname *",-1)),o(e("input",{"onUpdate:modelValue":t[1]||(t[1]=s=>l.value.first_name=s),type:"text",required:"",class:"input"},null,512),[[d,l.value.first_name]])]),e("div",null,[t[11]||(t[11]=e("label",{class:"block text-sm font-medium mb-1"},"Nachname *",-1)),o(e("input",{"onUpdate:modelValue":t[2]||(t[2]=s=>l.value.last_name=s),type:"text",required:"",class:"input"},null,512),[[d,l.value.last_name]])])]),e("div",null,[t[12]||(t[12]=e("label",{class:"block text-sm font-medium mb-1"},"E-Mail *",-1)),o(e("input",{"onUpdate:modelValue":t[3]||(t[3]=s=>l.value.email=s),type:"email",required:"",class:"input"},null,512),[[d,l.value.email]])]),e("div",null,[t[13]||(t[13]=e("label",{class:"block text-sm font-medium mb-1"},"Passwort *",-1)),o(e("input",{"onUpdate:modelValue":t[4]||(t[4]=s=>l.value.password=s),type:"password",required:"",class:"input"},null,512),[[d,l.value.password]])]),e("div",null,[t[14]||(t[14]=e("label",{class:"block text-sm font-medium mb-1"},"Telefon",-1)),o(e("input",{"onUpdate:modelValue":t[5]||(t[5]=s=>l.value.phone=s),type:"tel",class:"input"},null,512),[[d,l.value.phone]])]),x(f).isChef?(i(),n("div",J,[t[16]||(t[16]=e("label",{class:"block text-sm font-medium mb-1"},"Rolle",-1)),o(e("select",{"onUpdate:modelValue":t[6]||(t[6]=s=>l.value.role=s),class:"input"},[...t[15]||(t[15]=[e("option",{value:"mitarbeiter"},"Mitarbeiter",-1),e("option",{value:"disponent"},"Disponent",-1)])],512),[[q,l.value.role]])])):y("",!0),e("div",O,[e("button",{type:"button",class:"btn btn-secondary",onClick:t[7]||(t[7]=s=>u.value=!1)},"Abbrechen"),t[17]||(t[17]=e("button",{type:"submit",class:"btn btn-primary"},"Erstellen",-1))])],32)])])):y("",!0)]))}});export{Y as default};

1
dist/assets/index-38_8_Zmu.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

29
dist/assets/index-OvQoqblD.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -18,8 +18,8 @@
<!-- Android -->
<meta name="mobile-web-app-capable" content="yes" />
<script type="module" crossorigin src="/app/assets/index-DFiGttzi.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-CA5dNQfW.css">
<script type="module" crossorigin src="/app/assets/index-OvQoqblD.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-38_8_Zmu.css">
</head>
<body class="bg-gray-50 dark:bg-gray-900">
<div id="app"></div>

View File

@@ -1,16 +1,87 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import LockScreen from '@/components/LockScreen.vue'
const authStore = useAuthStore()
const isLocked = ref(false)
const lastActiveTime = ref(Date.now())
const LOCK_TIMEOUT = 30000 // 30 seconds of inactivity
// Check if lock is enabled
const lockEnabled = computed(() => {
return localStorage.getItem('lockMethod') &&
localStorage.getItem('lockMethod') !== 'none' &&
authStore.isAuthenticated
})
onMounted(async () => {
if (authStore.hasStoredToken) {
await authStore.fetchCurrentUser()
}
// Check if we should show lock screen on app start
if (lockEnabled.value) {
const lastUnlock = localStorage.getItem('lastUnlockTime')
const now = Date.now()
// Lock if no previous unlock or more than 30 seconds ago
if (!lastUnlock || (now - parseInt(lastUnlock)) > LOCK_TIMEOUT) {
isLocked.value = true
}
}
// Listen for visibility changes (app goes to background/foreground)
document.addEventListener('visibilitychange', handleVisibilityChange)
// Track user activity
document.addEventListener('touchstart', updateLastActive)
document.addEventListener('click', updateLastActive)
document.addEventListener('keydown', updateLastActive)
})
onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
document.removeEventListener('touchstart', updateLastActive)
document.removeEventListener('click', updateLastActive)
document.removeEventListener('keydown', updateLastActive)
})
function updateLastActive() {
lastActiveTime.value = Date.now()
}
function handleVisibilityChange() {
if (document.visibilityState === 'hidden') {
// App went to background - record time
localStorage.setItem('backgroundTime', String(Date.now()))
} else if (document.visibilityState === 'visible') {
// App came back to foreground
if (lockEnabled.value) {
const backgroundTime = localStorage.getItem('backgroundTime')
const now = Date.now()
// Lock if was in background for more than 30 seconds
if (backgroundTime && (now - parseInt(backgroundTime)) > LOCK_TIMEOUT) {
isLocked.value = true
}
}
}
}
function handleUnlock() {
isLocked.value = false
localStorage.setItem('lastUnlockTime', String(Date.now()))
}
</script>
<template>
<router-view />
<!-- Lock Screen Overlay -->
<LockScreen
v-if="isLocked && lockEnabled"
@unlocked="handleUnlock"
/>
<!-- Main App -->
<router-view v-show="!isLocked || !lockEnabled" />
</template>

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const emit = defineEmits<{
unlocked: []
}>()
const authStore = useAuthStore()
const lockMethod = ref<'biometric' | 'pin' | null>(null)
const pin = ref('')
const error = ref('')
const loading = ref(false)
const pinDigits = ref<string[]>(['', '', '', '', '', ''])
const currentDigit = ref(0)
onMounted(() => {
// Get user's preferred lock method
lockMethod.value = localStorage.getItem('lockMethod') as 'biometric' | 'pin' | null
// If biometric is set, try it automatically
if (lockMethod.value === 'biometric') {
tryBiometric()
}
})
async function tryBiometric() {
loading.value = true
error.value = ''
try {
// Check if WebAuthn is available
if (!window.PublicKeyCredential) {
throw new Error('Biometrie nicht verfügbar')
}
// Get stored credential ID
const credentialId = localStorage.getItem('biometricCredentialId')
if (!credentialId) {
throw new Error('Keine biometrischen Daten gespeichert')
}
// Request biometric authentication
const credential = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(32),
timeout: 60000,
userVerification: 'required',
allowCredentials: [{
id: Uint8Array.from(atob(credentialId), c => c.charCodeAt(0)),
type: 'public-key',
transports: ['internal']
}]
}
})
if (credential) {
emit('unlocked')
}
} catch (e: any) {
console.error('Biometric auth failed:', e)
error.value = 'Biometrie fehlgeschlagen. Bitte PIN verwenden.'
lockMethod.value = 'pin'
}
loading.value = false
}
function addDigit(digit: string) {
if (currentDigit.value < 6) {
pinDigits.value[currentDigit.value] = digit
currentDigit.value++
if (currentDigit.value === 6) {
verifyPin()
}
}
}
function removeDigit() {
if (currentDigit.value > 0) {
currentDigit.value--
pinDigits.value[currentDigit.value] = ''
}
error.value = ''
}
function clearPin() {
pinDigits.value = ['', '', '', '', '', '']
currentDigit.value = 0
error.value = ''
}
async function verifyPin() {
const enteredPin = pinDigits.value.join('')
const storedPin = localStorage.getItem('appPin')
if (enteredPin === storedPin) {
emit('unlocked')
} else {
error.value = 'Falscher PIN'
// Shake animation + clear after delay
setTimeout(clearPin, 500)
}
}
function switchToPin() {
lockMethod.value = 'pin'
error.value = ''
}
</script>
<template>
<div class="fixed inset-0 bg-gradient-to-br from-purple-600 to-indigo-700 flex flex-col items-center justify-center z-50">
<!-- Logo -->
<div class="text-6xl mb-4">🔐</div>
<h1 class="text-2xl font-bold text-white mb-2">SeCu</h1>
<p class="text-white/70 mb-8">Bitte entsperren</p>
<!-- Biometric -->
<div v-if="lockMethod === 'biometric' && !error" class="text-center">
<button
@click="tryBiometric"
:disabled="loading"
class="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center text-4xl mb-4 hover:bg-white/30 transition"
>
<span v-if="loading" class="animate-spin"></span>
<span v-else>👆</span>
</button>
<p class="text-white/70 text-sm">Fingerabdruck oder Face ID</p>
<button
@click="switchToPin"
class="mt-4 text-white/50 text-sm underline"
>
Stattdessen PIN verwenden
</button>
</div>
<!-- PIN Entry -->
<div v-else class="text-center">
<!-- PIN Dots -->
<div class="flex space-x-3 mb-8">
<div
v-for="(digit, i) in pinDigits"
:key="i"
:class="[
'w-4 h-4 rounded-full transition-all',
digit ? 'bg-white scale-110' : 'bg-white/30',
error ? 'animate-shake bg-red-400' : ''
]"
></div>
</div>
<!-- Error -->
<p v-if="error" class="text-red-300 text-sm mb-4">{{ error }}</p>
<!-- Number Pad -->
<div class="grid grid-cols-3 gap-4 max-w-xs">
<button
v-for="n in [1,2,3,4,5,6,7,8,9]"
:key="n"
@click="addDigit(String(n))"
class="w-16 h-16 bg-white/20 rounded-full text-white text-2xl font-semibold hover:bg-white/30 transition"
>
{{ n }}
</button>
<button
v-if="lockMethod === 'biometric' || localStorage.getItem('biometricCredentialId')"
@click="tryBiometric"
class="w-16 h-16 bg-white/20 rounded-full text-white text-2xl hover:bg-white/30 transition"
>
👆
</button>
<div v-else class="w-16 h-16"></div>
<button
@click="addDigit('0')"
class="w-16 h-16 bg-white/20 rounded-full text-white text-2xl font-semibold hover:bg-white/30 transition"
>
0
</button>
<button
@click="removeDigit"
class="w-16 h-16 bg-white/10 rounded-full text-white text-xl hover:bg-white/20 transition"
>
</button>
</div>
</div>
</div>
</template>
<style scoped>
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.animate-shake {
animation: shake 0.3s ease-in-out;
}
</style>

View File

@@ -0,0 +1,333 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
const emit = defineEmits<{
close: []
}>()
const step = ref<'menu' | 'setup-pin' | 'confirm-pin' | 'setup-biometric'>('menu')
const currentPin = ref('')
const confirmPin = ref('')
const error = ref('')
const pinDigits = ref<string[]>(['', '', '', '', '', ''])
const currentDigit = ref(0)
const hasBiometric = ref(false)
const hasPin = ref(false)
const hasBiometricSupport = ref(false)
const lockMethod = ref<'biometric' | 'pin' | 'none'>('none')
onMounted(async () => {
// Check what's configured
hasPin.value = !!localStorage.getItem('appPin')
hasBiometric.value = !!localStorage.getItem('biometricCredentialId')
lockMethod.value = localStorage.getItem('lockMethod') as any || 'none'
// Check if device supports biometric
if (window.PublicKeyCredential) {
try {
hasBiometricSupport.value = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
} catch {
hasBiometricSupport.value = false
}
}
})
function addDigit(digit: string) {
if (currentDigit.value < 6) {
pinDigits.value[currentDigit.value] = digit
currentDigit.value++
if (currentDigit.value === 6) {
if (step.value === 'setup-pin') {
currentPin.value = pinDigits.value.join('')
clearPinInput()
step.value = 'confirm-pin'
} else if (step.value === 'confirm-pin') {
confirmPin.value = pinDigits.value.join('')
verifyPinMatch()
}
}
}
}
function removeDigit() {
if (currentDigit.value > 0) {
currentDigit.value--
pinDigits.value[currentDigit.value] = ''
}
error.value = ''
}
function clearPinInput() {
pinDigits.value = ['', '', '', '', '', '']
currentDigit.value = 0
error.value = ''
}
function verifyPinMatch() {
if (currentPin.value === confirmPin.value) {
localStorage.setItem('appPin', currentPin.value)
localStorage.setItem('lockMethod', 'pin')
hasPin.value = true
lockMethod.value = 'pin'
step.value = 'menu'
currentPin.value = ''
confirmPin.value = ''
clearPinInput()
} else {
error.value = 'PINs stimmen nicht überein'
setTimeout(() => {
clearPinInput()
step.value = 'setup-pin'
currentPin.value = ''
confirmPin.value = ''
}, 1000)
}
}
async function setupBiometric() {
step.value = 'setup-biometric'
error.value = ''
try {
if (!window.PublicKeyCredential) {
throw new Error('WebAuthn nicht unterstützt')
}
// Generate a random user ID
const userId = new Uint8Array(16)
crypto.getRandomValues(userId)
// Create credential
const credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(32),
rp: {
name: 'SeCu',
id: window.location.hostname
},
user: {
id: userId,
name: localStorage.getItem('userEmail') || 'user',
displayName: 'SeCu User'
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 } // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform',
userVerification: 'required',
residentKey: 'preferred'
},
timeout: 60000,
attestation: 'none'
}
}) as PublicKeyCredential
if (credential) {
// Store credential ID
const credentialId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId)))
localStorage.setItem('biometricCredentialId', credentialId)
localStorage.setItem('lockMethod', 'biometric')
hasBiometric.value = true
lockMethod.value = 'biometric'
step.value = 'menu'
}
} catch (e: any) {
console.error('Biometric setup failed:', e)
error.value = e.message || 'Biometrie-Einrichtung fehlgeschlagen'
step.value = 'menu'
}
}
function disableLock() {
if (confirm('App-Sperre wirklich deaktivieren?')) {
localStorage.removeItem('appPin')
localStorage.removeItem('biometricCredentialId')
localStorage.removeItem('lockMethod')
hasPin.value = false
hasBiometric.value = false
lockMethod.value = 'none'
}
}
function setLockMethod(method: 'biometric' | 'pin') {
localStorage.setItem('lockMethod', method)
lockMethod.value = method
}
</script>
<template>
<div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-r from-purple-600 to-indigo-600 p-6 text-white">
<div class="flex justify-between items-center">
<h2 class="text-xl font-bold">🔐 App-Sicherheit</h2>
<button @click="emit('close')" class="text-white/70 hover:text-white"></button>
</div>
</div>
<!-- Content -->
<div class="p-6">
<!-- Menu -->
<div v-if="step === 'menu'" class="space-y-4">
<p class="text-gray-600 text-sm mb-4">
Schütze deine App mit Fingerabdruck, Face ID oder PIN.
</p>
<!-- Current Status -->
<div class="bg-gray-50 rounded-lg p-4 mb-4">
<div class="flex items-center justify-between">
<span class="text-gray-700">Aktueller Schutz:</span>
<span :class="[
'px-3 py-1 rounded-full text-sm font-medium',
lockMethod === 'none' ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'
]">
{{ lockMethod === 'biometric' ? '👆 Biometrie' : lockMethod === 'pin' ? '🔢 PIN' : '❌ Aus' }}
</span>
</div>
</div>
<!-- Options -->
<div class="space-y-3">
<!-- Biometric Option -->
<button
v-if="hasBiometricSupport"
@click="hasBiometric ? setLockMethod('biometric') : setupBiometric()"
:class="[
'w-full p-4 rounded-lg border-2 text-left transition flex items-center',
lockMethod === 'biometric'
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300'
]"
>
<span class="text-2xl mr-4">👆</span>
<div class="flex-1">
<div class="font-semibold">Fingerabdruck / Face ID</div>
<div class="text-sm text-gray-500">
{{ hasBiometric ? 'Eingerichtet' : 'Jetzt einrichten' }}
</div>
</div>
<span v-if="lockMethod === 'biometric'" class="text-purple-600"></span>
</button>
<!-- PIN Option -->
<button
@click="hasPin ? setLockMethod('pin') : (step = 'setup-pin')"
:class="[
'w-full p-4 rounded-lg border-2 text-left transition flex items-center',
lockMethod === 'pin'
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300'
]"
>
<span class="text-2xl mr-4">🔢</span>
<div class="flex-1">
<div class="font-semibold">PIN-Code (6 Ziffern)</div>
<div class="text-sm text-gray-500">
{{ hasPin ? 'Eingerichtet' : 'Jetzt einrichten' }}
</div>
</div>
<span v-if="lockMethod === 'pin'" class="text-purple-600"></span>
</button>
<!-- Disable -->
<button
v-if="lockMethod !== 'none'"
@click="disableLock"
class="w-full p-4 rounded-lg border-2 border-red-200 text-left transition flex items-center hover:border-red-300 hover:bg-red-50"
>
<span class="text-2xl mr-4">🔓</span>
<div class="flex-1">
<div class="font-semibold text-red-700">Sperre deaktivieren</div>
<div class="text-sm text-gray-500">Nicht empfohlen</div>
</div>
</button>
</div>
<!-- Change PIN -->
<button
v-if="hasPin"
@click="step = 'setup-pin'"
class="w-full text-center text-purple-600 text-sm hover:underline mt-4"
>
PIN ändern
</button>
</div>
<!-- Setup PIN -->
<div v-else-if="step === 'setup-pin' || step === 'confirm-pin'" class="text-center">
<h3 class="text-lg font-semibold mb-2">
{{ step === 'setup-pin' ? 'Neuen PIN eingeben' : 'PIN bestätigen' }}
</h3>
<p class="text-gray-500 text-sm mb-6">6 Ziffern</p>
<!-- PIN Dots -->
<div class="flex justify-center space-x-3 mb-8">
<div
v-for="(digit, i) in pinDigits"
:key="i"
:class="[
'w-4 h-4 rounded-full transition-all',
digit ? 'bg-purple-600 scale-110' : 'bg-gray-300',
error ? 'bg-red-400 animate-shake' : ''
]"
></div>
</div>
<!-- Error -->
<p v-if="error" class="text-red-500 text-sm mb-4">{{ error }}</p>
<!-- Number Pad -->
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
<button
v-for="n in [1,2,3,4,5,6,7,8,9]"
:key="n"
@click="addDigit(String(n))"
class="w-14 h-14 bg-gray-100 rounded-full text-xl font-semibold hover:bg-gray-200 transition"
>
{{ n }}
</button>
<button
@click="step = 'menu'; clearPinInput()"
class="w-14 h-14 text-gray-500 text-sm"
>
Zurück
</button>
<button
@click="addDigit('0')"
class="w-14 h-14 bg-gray-100 rounded-full text-xl font-semibold hover:bg-gray-200 transition"
>
0
</button>
<button
@click="removeDigit"
class="w-14 h-14 text-xl hover:bg-gray-100 rounded-full transition"
>
</button>
</div>
</div>
<!-- Setup Biometric -->
<div v-else-if="step === 'setup-biometric'" class="text-center py-8">
<div class="text-6xl mb-4 animate-pulse">👆</div>
<h3 class="text-lg font-semibold mb-2">Biometrie einrichten</h3>
<p class="text-gray-500 text-sm mb-4">
Bitte Fingerabdruck scannen oder Face ID verwenden...
</p>
<p v-if="error" class="text-red-500 text-sm">{{ error }}</p>
<button
@click="step = 'menu'"
class="mt-4 text-gray-500 text-sm hover:underline"
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { api } from '@/api'
import SecuritySettings from '@/components/SecuritySettings.vue'
const authStore = useAuthStore()
@@ -11,6 +12,32 @@ const confirmPassword = ref('')
const loading = ref(false)
const message = ref('')
const error = ref('')
const showSecuritySettings = ref(false)
// App lock status
const lockMethod = ref<string | null>(null)
onMounted(() => {
lockMethod.value = localStorage.getItem('lockMethod')
// Store email for biometric setup
if (authStore.user?.email) {
localStorage.setItem('userEmail', authStore.user.email)
}
})
const lockStatusText = computed(() => {
if (!lockMethod.value || lockMethod.value === 'none') return 'Deaktiviert'
if (lockMethod.value === 'biometric') return 'Fingerabdruck / Face ID'
if (lockMethod.value === 'pin') return 'PIN-Code'
return 'Unbekannt'
})
const lockStatusClass = computed(() => {
if (!lockMethod.value || lockMethod.value === 'none') {
return 'bg-red-100 text-red-700'
}
return 'bg-green-100 text-green-700'
})
async function changePassword() {
if (newPassword.value !== confirmPassword.value) {
@@ -42,6 +69,11 @@ async function changePassword() {
loading.value = false
}
}
function handleSecurityClose() {
showSecuritySettings.value = false
lockMethod.value = localStorage.getItem('lockMethod')
}
</script>
<template>
@@ -50,7 +82,7 @@ async function changePassword() {
<!-- Profile -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Profil</h2>
<h2 class="text-lg font-semibold mb-4">👤 Profil</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm text-gray-500">Name</label>
@@ -67,9 +99,33 @@ async function changePassword() {
</div>
</div>
<!-- App Security -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">🔐 App-Sicherheit</h2>
<p class="text-gray-600 text-sm mb-4">
Schütze deine App mit Fingerabdruck, Face ID oder PIN.
Bei jedem Öffnen der App musst du dich verifizieren.
</p>
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<div class="font-medium">App-Sperre</div>
<div :class="['inline-block px-2 py-1 rounded text-sm mt-1', lockStatusClass]">
{{ lockStatusText }}
</div>
</div>
<button
@click="showSecuritySettings = true"
class="btn btn-primary"
>
Konfigurieren
</button>
</div>
</div>
<!-- Change Password -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Passwort ändern</h2>
<h2 class="text-lg font-semibold mb-4">🔑 Passwort ändern</h2>
<form @submit.prevent="changePassword" class="space-y-4 max-w-md">
<div>
<label class="block text-sm font-medium mb-1">Aktuelles Passwort</label>
@@ -92,5 +148,11 @@ async function changePassword() {
</button>
</form>
</div>
<!-- Security Settings Modal -->
<SecuritySettings
v-if="showSecuritySettings"
@close="handleSecurityClose"
/>
</div>
</template>