feat: Add all module frontend views

Views added:
- ShiftsView (Schichtplanung)
- PatrolsView (Wächterkontrolle)
- IncidentsView (Vorfallberichte)
- VehiclesView (Fahrzeuge)
- DocumentsView (Dokumente)
- CustomersView (Kunden/CRM)
- BillingView (Abrechnung)
- ObjectsView (enhanced with contacts, instructions)

Updated:
- Router with all new routes
- Sidebar with complete navigation
This commit is contained in:
2026-03-12 21:23:01 +00:00
parent e5d09e9c80
commit 3ca75cc4f2
39 changed files with 1272 additions and 700 deletions

File diff suppressed because one or more lines are too long

1
dist/assets/AppLayout-AMYs4jcG.js 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

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-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};
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-CWxNv9Fc.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};

1
dist/assets/BillingView-TAMo2net.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/CustomersView-B4K9IT7m.js 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

1
dist/assets/DocumentsView-cD_00o40.js vendored Normal file

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-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:`
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-CWxNv9Fc.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:

1
dist/assets/IncidentsView-DPo8woI1.js vendored Normal file
View File

@@ -0,0 +1 @@
import{d as V,q as j,m as x,c as n,a as e,F as f,x as _,b as u,v as p,s as B,e as v,r,o,t as l,n as w}from"./index-CWxNv9Fc.js";const C={class:"p-6"},D={class:"flex justify-between items-center mb-6"},M={key:0,class:"text-center py-12"},S={key:1,class:"bg-gray-50 rounded-lg p-12 text-center text-gray-500"},G={key:2,class:"space-y-4"},I={class:"flex justify-between items-start"},K={class:"flex items-start space-x-3"},U={class:"text-2xl"},z={class:"font-semibold"},F={class:"text-sm text-gray-500"},L={class:"text-sm text-gray-400 mt-1"},O={class:"flex flex-col items-end space-y-1"},A={key:0,class:"mt-3 text-sm text-gray-600"},E={class:"mt-3 flex items-center text-xs text-gray-500"},N={key:0,class:"ml-4"},R={key:3,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50"},T={class:"bg-white rounded-lg shadow-xl w-full max-w-lg p-6"},$={class:"space-y-4"},q=["value"],P={class:"flex justify-end space-x-2 mt-6"},J=V({__name:"IncidentsView",setup(W){const c=r(!0),m=r([]),g=r([]),d=r(!1),i=r({title:"",description:"",category_id:"",severity:2,object_id:""});j(async()=>{await y()});async function y(){c.value=!0;try{const[a,t]=await Promise.all([x.get("/incidents"),x.get("/incidents/categories")]);m.value=a.data.incidents||[],g.value=t.data.categories||[]}catch(a){console.error(a)}c.value=!1}async function k(){try{await x.post("/incidents",i.value),d.value=!1,i.value={title:"",description:"",category_id:"",severity:2,object_id:""},await y()}catch(a){alert("Fehler: "+a.message)}}function h(a){return a>=4?"bg-red-100 text-red-700":a>=3?"bg-orange-100 text-orange-700":"bg-yellow-100 text-yellow-700"}function b(a){return{open:{text:"Offen",class:"bg-red-100 text-red-700"},in_progress:{text:"In Bearbeitung",class:"bg-yellow-100 text-yellow-700"},resolved:{text:"Gelöst",class:"bg-green-100 text-green-700"},closed:{text:"Geschlossen",class:"bg-gray-100 text-gray-700"}}[a]||{text:a,class:"bg-gray-100"}}return(a,t)=>(o(),n("div",C,[e("div",D,[t[6]||(t[6]=e("div",null,[e("h1",{class:"text-2xl font-bold"},"🚨 Vorfallberichte"),e("p",{class:"text-gray-500"},"Incidents dokumentieren und verfolgen")],-1)),e("button",{onClick:t[0]||(t[0]=s=>d.value=!0),class:"btn btn-primary"},"+ Vorfall melden")]),c.value?(o(),n("div",M,"Laden...")):m.value.length===0?(o(),n("div",S,[...t[7]||(t[7]=[e("p",{class:"text-4xl mb-4"},"✅",-1),e("p",null,"Keine Vorfälle gemeldet",-1)])])):(o(),n("div",G,[(o(!0),n(f,null,_(m.value,s=>(o(),n("div",{key:s.id,class:"bg-white rounded-lg shadow p-4"},[e("div",I,[e("div",K,[e("span",U,l(s.category_icon||"📋"),1),e("div",null,[e("h3",z,l(s.title),1),e("p",F,l(s.category_name)+" · "+l(s.object_name||"Ohne Objekt"),1),e("p",L,l(new Date(s.occurred_at).toLocaleString("de-DE")),1)])]),e("div",O,[e("span",{class:w(["px-2 py-1 text-xs rounded",b(s.status).class])},l(b(s.status).text),3),e("span",{class:w(["px-2 py-1 text-xs rounded",h(s.severity)])}," Stufe "+l(s.severity),3)])]),s.description?(o(),n("p",A,l(s.description),1)):v("",!0),e("div",E,[e("span",null,"Gemeldet von "+l(s.reporter_first)+" "+l(s.reporter_last),1),s.attachment_count?(o(),n("span",N,"📎 "+l(s.attachment_count)+" Anhänge",1)):v("",!0)])]))),128))])),d.value?(o(),n("div",R,[e("div",T,[t[14]||(t[14]=e("h2",{class:"text-xl font-bold mb-4"},"Vorfall melden",-1)),e("div",$,[e("div",null,[t[8]||(t[8]=e("label",{class:"block text-sm font-medium mb-1"},"Titel *",-1)),u(e("input",{"onUpdate:modelValue":t[1]||(t[1]=s=>i.value.title=s),class:"input",placeholder:"Kurze Beschreibung"},null,512),[[p,i.value.title]])]),e("div",null,[t[10]||(t[10]=e("label",{class:"block text-sm font-medium mb-1"},"Kategorie",-1)),u(e("select",{"onUpdate:modelValue":t[2]||(t[2]=s=>i.value.category_id=s),class:"input"},[t[9]||(t[9]=e("option",{value:""},"-- Wählen --",-1)),(o(!0),n(f,null,_(g.value,s=>(o(),n("option",{key:s.id,value:s.id},l(s.icon)+" "+l(s.name),9,q))),128))],512),[[B,i.value.category_id]])]),e("div",null,[t[11]||(t[11]=e("label",{class:"block text-sm font-medium mb-1"},"Schweregrad (1-5)",-1)),u(e("input",{"onUpdate:modelValue":t[3]||(t[3]=s=>i.value.severity=s),type:"range",min:"1",max:"5",class:"w-full"},null,512),[[p,i.value.severity,void 0,{number:!0}]]),t[12]||(t[12]=e("div",{class:"flex justify-between text-xs text-gray-500"},[e("span",null,"Gering"),e("span",null,"Kritisch")],-1))]),e("div",null,[t[13]||(t[13]=e("label",{class:"block text-sm font-medium mb-1"},"Beschreibung",-1)),u(e("textarea",{"onUpdate:modelValue":t[4]||(t[4]=s=>i.value.description=s),rows:"3",class:"input",placeholder:"Details..."},null,512),[[p,i.value.description]])])]),e("div",P,[e("button",{onClick:t[5]||(t[5]=s=>d.value=!1),class:"btn"},"Abbrechen"),e("button",{onClick:k,class:"btn btn-primary"},"Melden")])])])):v("",!0)]))}});export{J as default};

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-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};
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-CWxNv9Fc.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-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};
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-CWxNv9Fc.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

1
dist/assets/ObjectsView-CzlgFF9j.js vendored Normal file

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-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};
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-CWxNv9Fc.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

1
dist/assets/PatrolsView-CE7IkVbv.js vendored Normal file
View File

@@ -0,0 +1 @@
import{d as h,q as k,m,c as s,a as t,n as u,t as a,F as g,x as _,e as v,r as d,o as n}from"./index-CWxNv9Fc.js";const f={class:"p-6"},w={class:"flex space-x-1 border-b mb-6"},C={key:0,class:"text-center py-12"},R={key:1},j={key:0,class:"bg-gray-50 rounded-lg p-12 text-center text-gray-500"},M={key:1,class:"grid md:grid-cols-2 lg:grid-cols-3 gap-4"},D={class:"flex justify-between items-start"},B={class:"font-semibold"},E={class:"text-sm text-gray-500"},K={class:"text-xs text-gray-400 mt-1"},L={class:"text-2xl"},T={class:"mt-3 p-2 bg-gray-100 rounded text-xs font-mono break-all"},V={key:2},$={key:0,class:"bg-gray-50 rounded-lg p-12 text-center text-gray-500"},A={key:1,class:"space-y-4"},F={class:"flex justify-between items-start"},N={class:"font-semibold"},P={class:"text-sm text-gray-500"},S={class:"mt-3 flex gap-4 text-sm text-gray-600"},q={key:0},z={key:1},I={key:3},O={key:0,class:"bg-gray-50 rounded-lg p-12 text-center text-gray-500"},Q={key:1,class:"bg-white rounded-lg shadow overflow-hidden"},W={class:"min-w-full divide-y divide-gray-200"},Z={class:"divide-y divide-gray-200"},G={class:"px-4 py-3 text-sm"},H={class:"px-4 py-3 text-sm"},J={class:"px-4 py-3 text-sm"},U={class:"px-4 py-3 text-sm text-gray-500"},tt=h({__name:"PatrolsView",setup(X){const p=d(!0),r=d([]),c=d([]),x=d([]),l=d("checkpoints");k(async()=>{await y()});async function y(){p.value=!0;try{const[i,o,e]=await Promise.all([m.get("/patrols/checkpoints"),m.get("/patrols/routes"),m.get("/patrols/logs")]);r.value=i.data.checkpoints||[],c.value=o.data.routes||[],x.value=e.data.logs||[]}catch(i){console.error(i)}p.value=!1}function b(i){return new Date(i).toLocaleTimeString("de-DE",{hour:"2-digit",minute:"2-digit"})}return(i,o)=>(n(),s("div",f,[o[7]||(o[7]=t("div",{class:"flex justify-between items-center mb-6"},[t("div",null,[t("h1",{class:"text-2xl font-bold"},"📍 Wächterkontrolle"),t("p",{class:"text-gray-500"},"Checkpoints, Routen & Rundgänge")])],-1)),t("div",w,[t("button",{onClick:o[0]||(o[0]=e=>l.value="checkpoints"),class:u(["px-4 py-2 font-medium border-b-2 -mb-px",l.value==="checkpoints"?"border-blue-600 text-blue-600":"border-transparent"])}," Checkpoints ("+a(r.value.length)+") ",3),t("button",{onClick:o[1]||(o[1]=e=>l.value="routes"),class:u(["px-4 py-2 font-medium border-b-2 -mb-px",l.value==="routes"?"border-blue-600 text-blue-600":"border-transparent"])}," Routen ("+a(c.value.length)+") ",3),t("button",{onClick:o[2]||(o[2]=e=>l.value="logs"),class:u(["px-4 py-2 font-medium border-b-2 -mb-px",l.value==="logs"?"border-blue-600 text-blue-600":"border-transparent"])}," Rundgänge ",2)]),p.value?(n(),s("div",C,"Laden...")):l.value==="checkpoints"?(n(),s("div",R,[r.value.length===0?(n(),s("div",j,[...o[3]||(o[3]=[t("p",{class:"text-4xl mb-4"},"📍",-1),t("p",null,"Keine Checkpoints vorhanden",-1),t("p",{class:"text-sm mt-2"},"Erstelle Checkpoints mit QR-Codes für Rundgänge",-1)])])):(n(),s("div",M,[(n(!0),s(g,null,_(r.value,e=>(n(),s("div",{key:e.id,class:"bg-white rounded-lg shadow p-4"},[t("div",D,[t("div",null,[t("h3",B,a(e.name),1),t("p",E,a(e.object_name),1),t("p",K,a(e.location_description),1)]),t("span",L,a(e.checkpoint_type==="nfc"?"📶":"📱"),1)]),t("div",T,a(e.code),1)]))),128))]))])):l.value==="routes"?(n(),s("div",V,[c.value.length===0?(n(),s("div",$,[...o[4]||(o[4]=[t("p",{class:"text-4xl mb-4"},"🗺️",-1),t("p",null,"Keine Routen definiert",-1)])])):(n(),s("div",A,[(n(!0),s(g,null,_(c.value,e=>(n(),s("div",{key:e.id,class:"bg-white rounded-lg shadow p-4"},[t("div",F,[t("div",null,[t("h3",N,a(e.name),1),t("p",P,a(e.object_name),1)]),t("span",{class:u(["px-2 py-1 text-xs rounded",e.is_active?"bg-green-100 text-green-700":"bg-gray-100"])},a(e.is_active?"Aktiv":"Inaktiv"),3)]),t("div",S,[t("span",null,"📍 "+a(e.checkpoint_count||0)+" Checkpoints",1),e.time_limit_minutes?(n(),s("span",q,"⏱️ Max "+a(e.time_limit_minutes)+" Min",1)):v("",!0),e.interval_minutes?(n(),s("span",z,"🔄 Alle "+a(e.interval_minutes)+" Min",1)):v("",!0)])]))),128))]))])):l.value==="logs"?(n(),s("div",I,[x.value.length===0?(n(),s("div",O,[...o[5]||(o[5]=[t("p",{class:"text-4xl mb-4"},"📋",-1),t("p",null,"Keine Rundgänge heute",-1)])])):(n(),s("div",Q,[t("table",W,[o[6]||(o[6]=t("thead",{class:"bg-gray-50"},[t("tr",null,[t("th",{class:"px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"},"Zeit"),t("th",{class:"px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"},"Mitarbeiter"),t("th",{class:"px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"},"Checkpoint"),t("th",{class:"px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"},"Objekt")])],-1)),t("tbody",Z,[(n(!0),s(g,null,_(x.value,e=>(n(),s("tr",{key:e.id,class:"hover:bg-gray-50"},[t("td",G,a(b(e.scanned_at)),1),t("td",H,a(e.first_name)+" "+a(e.last_name),1),t("td",J,a(e.checkpoint_name),1),t("td",U,a(e.object_name),1)]))),128))])])]))])):v("",!0)]))}});export{tt 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

1
dist/assets/ShiftsView-gFZrEy1d.js vendored Normal file
View File

@@ -0,0 +1 @@
import{d as E,q as $,m as x,c as i,a as t,F as h,x as k,e as S,t as r,b as c,v,r as d,k as N,o,C}from"./index-CWxNv9Fc.js";const U={class:"p-6"},j={class:"flex justify-between items-center mb-6"},L={class:"bg-white rounded-lg shadow p-4 mb-6"},M={class:"flex flex-wrap gap-2"},z={key:0,class:"text-gray-500"},A={class:"bg-white rounded-lg shadow overflow-hidden"},T={class:"flex items-center justify-between p-4 border-b"},W={class:"font-semibold"},P={class:"grid grid-cols-7 divide-x"},R={class:"text-xs font-semibold text-gray-500 mb-2"},q={key:0,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50"},I={class:"bg-white rounded-lg shadow-xl w-full max-w-md p-6"},K={class:"space-y-4"},O={class:"grid grid-cols-2 gap-4"},G={class:"grid grid-cols-2 gap-4"},H={class:"flex justify-end space-x-2 mt-6"},X=E({__name:"ShiftsView",setup(J){const D=d(!0),p=d([]),w=d([]),f=d(!1),u=d(new Date),l=d({name:"",start_time:"06:00",end_time:"14:00",break_minutes:30,color:"#3B82F6",is_night_shift:!1});$(async()=>{await b()});async function b(){D.value=!0;try{const[n,e]=await Promise.all([x.get("/shifts/definitions"),x.get(`/shifts/assignments?start=${y(g())}&end=${y(_())}`)]);p.value=n.data.shifts||[],w.value=e.data.assignments||[]}catch(n){console.error(n)}D.value=!1}function g(){const n=new Date(u.value);return n.setDate(n.getDate()-n.getDay()+1),n}function _(){const n=new Date(g());return n.setDate(n.getDate()+6),n}function y(n){return n.toISOString().split("T")[0]}const V=N(()=>{const n=[],e=g();for(let s=0;s<7;s++){const a=new Date(e);a.setDate(a.getDate()+s),n.push({date:y(a),label:a.toLocaleDateString("de-DE",{weekday:"short",day:"2-digit"})})}return n});async function F(){try{await x.post("/shifts/definitions",l.value),f.value=!1,l.value={name:"",start_time:"06:00",end_time:"14:00",break_minutes:30,color:"#3B82F6",is_night_shift:!1},await b()}catch(n){alert("Fehler: "+n.message)}}function B(n){return w.value.filter(e=>e.date===n)}return(n,e)=>(o(),i("div",U,[t("div",j,[e[9]||(e[9]=t("div",null,[t("h1",{class:"text-2xl font-bold"},"📅 Schichtplanung"),t("p",{class:"text-gray-500"},"Dienstpläne verwalten")],-1)),t("button",{onClick:e[0]||(e[0]=s=>f.value=!0),class:"btn btn-primary"},"+ Schicht definieren")]),t("div",L,[e[10]||(e[10]=t("h2",{class:"font-semibold mb-3"},"Schicht-Typen",-1)),t("div",M,[(o(!0),i(h,null,k(p.value,s=>{var a,m;return o(),i("div",{key:s.id,style:C({backgroundColor:s.color+"20",borderColor:s.color}),class:"px-3 py-1 rounded-full border text-sm"},r(s.name)+" ("+r((a=s.start_time)==null?void 0:a.slice(0,5))+" - "+r((m=s.end_time)==null?void 0:m.slice(0,5))+") ",5)}),128)),p.value.length===0?(o(),i("div",z,"Keine Schichten definiert")):S("",!0)])]),t("div",A,[t("div",T,[t("button",{onClick:e[1]||(e[1]=s=>{u.value.setDate(u.value.getDate()-7),b()}),class:"text-gray-600 hover:text-gray-900"},"← Vorherige"),t("span",W,r(g().toLocaleDateString("de-DE"))+" - "+r(_().toLocaleDateString("de-DE")),1),t("button",{onClick:e[2]||(e[2]=s=>{u.value.setDate(u.value.getDate()+7),b()}),class:"text-gray-600 hover:text-gray-900"},"Nächste →")]),t("div",P,[(o(!0),i(h,null,k(V.value,s=>(o(),i("div",{key:s.date,class:"min-h-32 p-2"},[t("div",R,r(s.label),1),(o(!0),i(h,null,k(B(s.date),a=>{var m;return o(),i("div",{key:a.id,style:C({backgroundColor:a.color+"40"}),class:"text-xs p-1 rounded mb-1"},r(a.first_name)+" "+r((m=a.last_name)==null?void 0:m.charAt(0))+". ",5)}),128))]))),128))])]),f.value?(o(),i("div",q,[t("div",I,[e[16]||(e[16]=t("h2",{class:"text-xl font-bold mb-4"},"Neue Schicht definieren",-1)),t("div",K,[t("div",null,[e[11]||(e[11]=t("label",{class:"block text-sm font-medium mb-1"},"Name",-1)),c(t("input",{"onUpdate:modelValue":e[3]||(e[3]=s=>l.value.name=s),class:"input",placeholder:"z.B. Frühschicht"},null,512),[[v,l.value.name]])]),t("div",O,[t("div",null,[e[12]||(e[12]=t("label",{class:"block text-sm font-medium mb-1"},"Beginn",-1)),c(t("input",{"onUpdate:modelValue":e[4]||(e[4]=s=>l.value.start_time=s),type:"time",class:"input"},null,512),[[v,l.value.start_time]])]),t("div",null,[e[13]||(e[13]=t("label",{class:"block text-sm font-medium mb-1"},"Ende",-1)),c(t("input",{"onUpdate:modelValue":e[5]||(e[5]=s=>l.value.end_time=s),type:"time",class:"input"},null,512),[[v,l.value.end_time]])])]),t("div",G,[t("div",null,[e[14]||(e[14]=t("label",{class:"block text-sm font-medium mb-1"},"Pause (Min.)",-1)),c(t("input",{"onUpdate:modelValue":e[6]||(e[6]=s=>l.value.break_minutes=s),type:"number",class:"input"},null,512),[[v,l.value.break_minutes,void 0,{number:!0}]])]),t("div",null,[e[15]||(e[15]=t("label",{class:"block text-sm font-medium mb-1"},"Farbe",-1)),c(t("input",{"onUpdate:modelValue":e[7]||(e[7]=s=>l.value.color=s),type:"color",class:"w-full h-10 rounded"},null,512),[[v,l.value.color]])])])]),t("div",H,[t("button",{onClick:e[8]||(e[8]=s=>f.value=!1),class:"btn"},"Abbrechen"),t("button",{onClick:F,class:"btn btn-primary"},"Erstellen")])])])):S("",!0)]))}});export{X 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-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};
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-CWxNv9Fc.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-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};
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-CWxNv9Fc.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/VehiclesView-C2kXdRXZ.js vendored Normal file
View File

@@ -0,0 +1 @@
import{d as _,q as h,m as y,c as n,a as e,F as V,x as z,b as r,v as u,s as B,e as g,r as p,o,t as i,n as f,p as k}from"./index-CWxNv9Fc.js";const D={class:"p-6"},F={class:"flex justify-between items-center mb-6"},M={key:0,class:"text-center py-12"},j={key:1,class:"bg-gray-50 rounded-lg p-12 text-center text-gray-500"},C={key:2,class:"grid md:grid-cols-2 lg:grid-cols-3 gap-4"},S={class:"flex justify-between items-start"},E={class:"font-semibold text-lg"},K={class:"text-gray-600"},U={key:0,class:"text-sm text-gray-400"},L={class:"mt-4 grid grid-cols-2 gap-2 text-sm"},N={key:3,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50"},T={class:"bg-white rounded-lg shadow-xl w-full max-w-md p-6"},A={class:"space-y-4"},W={class:"grid grid-cols-2 gap-4"},$={class:"grid grid-cols-2 gap-4"},q={class:"flex justify-end space-x-2 mt-6"},P=_({__name:"VehiclesView",setup(H){const c=p(!0),m=p([]),d=p(!1),s=p({license_plate:"",brand:"",model:"",year:2024,color:"",fuel_type:"diesel"});h(async()=>{await v()});async function v(){c.value=!0;try{const a=await y.get("/vehicles");m.value=a.data.vehicles||[]}catch(a){console.error(a)}c.value=!1}async function w(){try{await y.post("/vehicles",s.value),d.value=!1,s.value={license_plate:"",brand:"",model:"",year:2024,color:"",fuel_type:"diesel"},await v()}catch(a){alert("Fehler: "+a.message)}}function b(a){return{available:{text:"Verfügbar",class:"bg-green-100 text-green-700",icon:"✅"},in_use:{text:"Im Einsatz",class:"bg-blue-100 text-blue-700",icon:"🚗"},maintenance:{text:"Wartung",class:"bg-yellow-100 text-yellow-700",icon:"🔧"},retired:{text:"Stillgelegt",class:"bg-gray-100 text-gray-700",icon:"⛔"}}[a]||{text:a,class:"bg-gray-100",icon:""}}return(a,t)=>(o(),n("div",D,[e("div",F,[t[7]||(t[7]=e("div",null,[e("h1",{class:"text-2xl font-bold"},"🚗 Fahrzeuge"),e("p",{class:"text-gray-500"},"Fuhrpark verwalten")],-1)),e("button",{onClick:t[0]||(t[0]=l=>d.value=!0),class:"btn btn-primary"},"+ Fahrzeug")]),c.value?(o(),n("div",M,"Laden...")):m.value.length===0?(o(),n("div",j,[...t[8]||(t[8]=[e("p",{class:"text-4xl mb-4"},"🚗",-1),e("p",null,"Keine Fahrzeuge vorhanden",-1)])])):(o(),n("div",C,[(o(!0),n(V,null,z(m.value,l=>{var x;return o(),n("div",{key:l.id,class:"bg-white rounded-lg shadow p-4"},[e("div",S,[e("div",null,[e("h3",E,i(l.license_plate),1),e("p",K,i(l.brand)+" "+i(l.model),1),l.year?(o(),n("p",U,"Baujahr "+i(l.year),1)):g("",!0)]),e("span",{class:f(["px-2 py-1 text-xs rounded",b(l.status).class])},i(b(l.status).icon)+" "+i(b(l.status).text),3)]),e("div",L,[e("div",null,[t[9]||(t[9]=e("span",{class:"text-gray-500"},"KM-Stand:",-1)),k(" "+i(((x=l.current_mileage)==null?void 0:x.toLocaleString())||"-"),1)]),e("div",null,[t[10]||(t[10]=e("span",{class:"text-gray-500"},"Kraftstoff:",-1)),k(" "+i(l.fuel_type),1)]),l.tuev_expires?(o(),n("div",{key:0,class:f(["col-span-2",new Date(l.tuev_expires)<new Date?"text-red-600":""])}," TÜV: "+i(new Date(l.tuev_expires).toLocaleDateString("de-DE")),3)):g("",!0)])])}),128))])),d.value?(o(),n("div",N,[e("div",T,[t[17]||(t[17]=e("h2",{class:"text-xl font-bold mb-4"},"Neues Fahrzeug",-1)),e("div",A,[e("div",null,[t[11]||(t[11]=e("label",{class:"block text-sm font-medium mb-1"},"Kennzeichen *",-1)),r(e("input",{"onUpdate:modelValue":t[1]||(t[1]=l=>s.value.license_plate=l),class:"input",placeholder:"B-AB 1234"},null,512),[[u,s.value.license_plate]])]),e("div",W,[e("div",null,[t[12]||(t[12]=e("label",{class:"block text-sm font-medium mb-1"},"Marke",-1)),r(e("input",{"onUpdate:modelValue":t[2]||(t[2]=l=>s.value.brand=l),class:"input",placeholder:"VW"},null,512),[[u,s.value.brand]])]),e("div",null,[t[13]||(t[13]=e("label",{class:"block text-sm font-medium mb-1"},"Modell",-1)),r(e("input",{"onUpdate:modelValue":t[3]||(t[3]=l=>s.value.model=l),class:"input",placeholder:"Passat"},null,512),[[u,s.value.model]])])]),e("div",$,[e("div",null,[t[14]||(t[14]=e("label",{class:"block text-sm font-medium mb-1"},"Baujahr",-1)),r(e("input",{"onUpdate:modelValue":t[4]||(t[4]=l=>s.value.year=l),type:"number",class:"input"},null,512),[[u,s.value.year,void 0,{number:!0}]])]),e("div",null,[t[16]||(t[16]=e("label",{class:"block text-sm font-medium mb-1"},"Kraftstoff",-1)),r(e("select",{"onUpdate:modelValue":t[5]||(t[5]=l=>s.value.fuel_type=l),class:"input"},[...t[15]||(t[15]=[e("option",{value:"diesel"},"Diesel",-1),e("option",{value:"petrol"},"Benzin",-1),e("option",{value:"electric"},"Elektro",-1),e("option",{value:"hybrid"},"Hybrid",-1)])],512),[[B,s.value.fuel_type]])])])]),e("div",q,[e("button",{onClick:t[6]||(t[6]=l=>d.value=!1),class:"btn"},"Abbrechen"),e("button",{onClick:w,class:"btn btn-primary"},"Erstellen")])])])):g("",!0)]))}});export{P as default};

File diff suppressed because one or more lines are too long

1
dist/assets/index-BtrpDjiv.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

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-OvQoqblD.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-38_8_Zmu.css">
<script type="module" crossorigin src="/app/assets/index-CWxNv9Fc.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-BtrpDjiv.css">
</head>
<body class="bg-gray-50 dark:bg-gray-900">
<div id="app"></div>

View File

@@ -21,18 +21,34 @@ const navigation = computed(() => {
]
if (authStore.canManageUsers) {
items.push({ name: 'Mitarbeiter', href: '/users', icon: '👥' })
items.push(
{ name: 'Mitarbeiter', href: '/users', icon: '👥' },
{ name: 'Schichtplanung', href: '/shifts', icon: '📅' }
)
}
items.push(
{ name: 'Verfügbarkeit', href: '/availability', icon: '📅' },
{ name: 'Verfügbarkeit', href: '/availability', icon: '🗓️' },
{ name: 'Stundenzettel', href: '/timesheets', icon: '⏱️' },
{ name: 'Qualifikationen', href: '/qualifications', icon: '🎓' },
{ name: 'Objekte', href: '/objects', icon: '🏢' },
{ name: 'Rundgänge', href: '/patrols', icon: '📍' },
{ name: 'Vorfälle', href: '/incidents', icon: '🚨' },
{ name: 'Dokumente', href: '/documents', icon: '📁' },
)
if (authStore.canManageUsers) {
items.push(
{ name: 'Fahrzeuge', href: '/vehicles', icon: '🚗' },
{ name: 'Kunden', href: '/customers', icon: '🤝' }
)
}
if (authStore.isChef) {
items.push({ name: 'Module', href: '/modules', icon: '⚙️' })
items.push(
{ name: 'Abrechnung', href: '/billing', icon: '💰' },
{ name: 'Module', href: '/modules', icon: '⚙️' }
)
}
items.push(

View File

@@ -88,6 +88,45 @@ const router = createRouter({
path: 'objects',
name: 'objects',
component: () => import('@/views/ObjectsView.vue')
},
{
path: 'shifts',
name: 'shifts',
component: () => import('@/views/ShiftsView.vue'),
meta: { roles: ['chef', 'disponent'] }
},
{
path: 'patrols',
name: 'patrols',
component: () => import('@/views/PatrolsView.vue')
},
{
path: 'incidents',
name: 'incidents',
component: () => import('@/views/IncidentsView.vue')
},
{
path: 'vehicles',
name: 'vehicles',
component: () => import('@/views/VehiclesView.vue'),
meta: { roles: ['chef', 'disponent'] }
},
{
path: 'documents',
name: 'documents',
component: () => import('@/views/DocumentsView.vue')
},
{
path: 'customers',
name: 'customers',
component: () => import('@/views/CustomersView.vue'),
meta: { roles: ['chef', 'disponent'] }
},
{
path: 'billing',
name: 'billing',
component: () => import('@/views/BillingView.vue'),
meta: { roles: ['chef'] }
}
]
}

165
src/views/BillingView.vue Normal file
View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const invoices = ref<any[]>([])
const rates = ref<any[]>([])
const stats = ref<any>({})
const activeTab = ref<'invoices' | 'rates'>('invoices')
onMounted(async () => { await loadData() })
async function loadData() {
loading.value = true
try {
const [invRes, ratesRes, statsRes] = await Promise.all([
api.get<any>('/billing/invoices'),
api.get<any>('/billing/rates'),
api.get<any>('/billing/stats')
])
invoices.value = invRes.data.invoices || []
rates.value = ratesRes.data.rates || []
stats.value = statsRes.data.stats || {}
} catch (e) { console.error(e) }
loading.value = false
}
async function sendInvoice(id: string) {
try {
await api.put(`/billing/invoices/${id}/send`, {})
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
async function markPaid(id: string) {
try {
await api.put(`/billing/invoices/${id}/pay`, { payment_method: 'Überweisung' })
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
function statusBadge(s: string) {
const map: Record<string, any> = {
draft: { text: 'Entwurf', class: 'bg-gray-100 text-gray-700' },
sent: { text: 'Gesendet', class: 'bg-blue-100 text-blue-700' },
paid: { text: 'Bezahlt', class: 'bg-green-100 text-green-700' },
overdue: { text: 'Überfällig', class: 'bg-red-100 text-red-700' },
cancelled: { text: 'Storniert', class: 'bg-gray-100 text-gray-700' }
}
return map[s] || { text: s, class: 'bg-gray-100' }
}
function formatCurrency(n: number): string {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(n || 0)
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">💰 Abrechnung</h1>
<p class="text-gray-500">Rechnungen & Sätze</p>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Offene Rechnungen</p>
<p class="text-2xl font-bold text-blue-600">{{ stats.open_invoices || 0 }}</p>
<p class="text-sm text-gray-500">{{ formatCurrency(stats.open_amount) }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Überfällig</p>
<p class="text-2xl font-bold text-red-600">{{ stats.overdue_invoices || 0 }}</p>
<p class="text-sm text-gray-500">{{ formatCurrency(stats.overdue_amount) }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Bezahlt (30 Tage)</p>
<p class="text-2xl font-bold text-green-600">{{ formatCurrency(stats.paid_last_30_days) }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Stundensätze</p>
<p class="text-2xl font-bold">{{ rates.length }}</p>
</div>
</div>
<!-- Tabs -->
<div class="flex space-x-1 border-b mb-6">
<button @click="activeTab = 'invoices'"
:class="['px-4 py-2 font-medium border-b-2 -mb-px', activeTab === 'invoices' ? 'border-blue-600 text-blue-600' : 'border-transparent']">
Rechnungen
</button>
<button @click="activeTab = 'rates'"
:class="['px-4 py-2 font-medium border-b-2 -mb-px', activeTab === 'rates' ? 'border-blue-600 text-blue-600' : 'border-transparent']">
Stundensätze
</button>
</div>
<div v-if="loading" class="text-center py-12">Laden...</div>
<!-- Invoices -->
<div v-else-if="activeTab === 'invoices'">
<div v-if="invoices.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">📄</p>
<p>Keine Rechnungen vorhanden</p>
</div>
<div v-else class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nr.</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Kunde</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Betrag</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium">{{ inv.invoice_number }}</td>
<td class="px-4 py-3 text-sm">{{ inv.customer_name }}</td>
<td class="px-4 py-3 text-sm">{{ new Date(inv.invoice_date).toLocaleDateString('de-DE') }}</td>
<td class="px-4 py-3 text-sm text-right font-semibold">{{ formatCurrency(inv.total) }}</td>
<td class="px-4 py-3">
<span :class="['px-2 py-1 text-xs rounded', statusBadge(inv.status).class]">
{{ statusBadge(inv.status).text }}
</span>
</td>
<td class="px-4 py-3 text-right text-sm space-x-2">
<button v-if="inv.status === 'draft'" @click="sendInvoice(inv.id)" class="text-blue-600 hover:underline">Senden</button>
<button v-if="inv.status === 'sent' || inv.status === 'overdue'" @click="markPaid(inv.id)" class="text-green-600 hover:underline">Bezahlt</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Rates -->
<div v-else-if="activeTab === 'rates'">
<div v-if="rates.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">💵</p>
<p>Keine Stundensätze definiert</p>
</div>
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="r in rates" :key="r.id" class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold">{{ r.name }}</h3>
<p class="text-sm text-gray-500">{{ r.customer_name || 'Allgemein' }}</p>
</div>
<span v-if="r.is_default" class="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded">Standard</span>
</div>
<p class="text-2xl font-bold mt-2">{{ formatCurrency(r.amount) }}</p>
<p class="text-sm text-gray-500">pro {{ r.rate_type === 'hourly' ? 'Stunde' : r.rate_type === 'daily' ? 'Tag' : 'Monat' }}</p>
</div>
</div>
</div>
</div>
</template>

177
src/views/CustomersView.vue Normal file
View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const customers = ref<any[]>([])
const showModal = ref(false)
const selectedCustomer = ref<any>(null)
const form = ref({ company_name: '', contact_person: '', email: '', phone: '', address: '', city: '', postal_code: '' })
onMounted(async () => { await loadData() })
async function loadData() {
loading.value = true
try {
const res = await api.get<any>('/customers')
customers.value = res.data.customers || []
} catch (e) { console.error(e) }
loading.value = false
}
async function loadCustomer(id: string) {
try {
const res = await api.get<any>(`/customers/${id}`)
selectedCustomer.value = res.data
} catch (e) { console.error(e) }
}
async function createCustomer() {
try {
await api.post('/customers', form.value)
showModal.value = false
form.value = { company_name: '', contact_person: '', email: '', phone: '', address: '', city: '', postal_code: '' }
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
function statusBadge(s: string) {
const map: Record<string, any> = {
active: { text: 'Aktiv', class: 'bg-green-100 text-green-700' },
inactive: { text: 'Inaktiv', class: 'bg-gray-100 text-gray-700' },
prospect: { text: 'Interessent', class: 'bg-blue-100 text-blue-700' }
}
return map[s] || { text: s, class: 'bg-gray-100' }
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">👥 Kunden / CRM</h1>
<p class="text-gray-500">Kundenverwaltung & Verträge</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Kunde</button>
</div>
<div class="flex gap-6">
<!-- Customer List -->
<div class="w-1/2">
<div v-if="loading" class="text-center py-12">Laden...</div>
<div v-else-if="customers.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">👥</p>
<p>Keine Kunden vorhanden</p>
</div>
<div v-else class="space-y-2">
<div v-for="c in customers" :key="c.id"
@click="loadCustomer(c.id)"
:class="['bg-white rounded-lg shadow p-4 cursor-pointer hover:shadow-md transition-shadow',
selectedCustomer?.id === c.id ? 'ring-2 ring-blue-500' : '']">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold">{{ c.company_name }}</h3>
<p class="text-sm text-gray-500">{{ c.contact_person }}</p>
</div>
<span :class="['px-2 py-1 text-xs rounded', statusBadge(c.status).class]">
{{ statusBadge(c.status).text }}
</span>
</div>
<div class="mt-2 flex gap-4 text-xs text-gray-500">
<span v-if="c.active_contracts">📄 {{ c.active_contracts }} Verträge</span>
<span v-if="c.object_count">🏢 {{ c.object_count }} Objekte</span>
</div>
</div>
</div>
</div>
<!-- Customer Detail -->
<div class="w-1/2">
<div v-if="!selectedCustomer" class="bg-gray-50 rounded-lg p-12 text-center text-gray-400">
<p>Wähle einen Kunden aus</p>
</div>
<div v-else class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-bold mb-4">{{ selectedCustomer.company_name }}</h2>
<div class="space-y-4">
<div>
<h3 class="text-sm font-semibold text-gray-500 mb-2">Kontakt</h3>
<p>{{ selectedCustomer.contact_person }}</p>
<p class="text-sm">📧 {{ selectedCustomer.email || '-' }}</p>
<p class="text-sm">📞 {{ selectedCustomer.phone || '-' }}</p>
</div>
<div>
<h3 class="text-sm font-semibold text-gray-500 mb-2">Adresse</h3>
<p>{{ selectedCustomer.address }}</p>
<p>{{ selectedCustomer.postal_code }} {{ selectedCustomer.city }}</p>
</div>
<div v-if="selectedCustomer.contracts?.length">
<h3 class="text-sm font-semibold text-gray-500 mb-2">Verträge ({{ selectedCustomer.contracts.length }})</h3>
<div v-for="con in selectedCustomer.contracts" :key="con.id" class="text-sm p-2 bg-gray-50 rounded mb-1">
<span class="font-medium">{{ con.title || con.contract_number }}</span>
<span v-if="con.monthly_value" class="ml-2 text-green-600">{{ con.monthly_value }}/Monat</span>
</div>
</div>
<div v-if="selectedCustomer.objects?.length">
<h3 class="text-sm font-semibold text-gray-500 mb-2">Objekte ({{ selectedCustomer.objects.length }})</h3>
<div v-for="obj in selectedCustomer.objects" :key="obj.id" class="text-sm">
🏢 {{ obj.name }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
<h2 class="text-xl font-bold mb-4">Neuer Kunde</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Firmenname *</label>
<input v-model="form.company_name" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Ansprechpartner</label>
<input v-model="form.contact_person" class="input" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">E-Mail</label>
<input v-model="form.email" type="email" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Telefon</label>
<input v-model="form.phone" class="input" />
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Adresse</label>
<input v-model="form.address" class="input" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">PLZ</label>
<input v-model="form.postal_code" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Stadt</label>
<input v-model="form.city" class="input" />
</div>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createCustomer" class="btn btn-primary">Erstellen</button>
</div>
</div>
</div>
</div>
</template>

137
src/views/DocumentsView.vue Normal file
View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const documents = ref<any[]>([])
const categories = ref<any[]>([])
const pendingDocs = ref<any[]>([])
const showModal = ref(false)
const form = ref({ title: '', description: '', category_id: '', file_url: '', is_mandatory: false })
onMounted(async () => { await loadData() })
async function loadData() {
loading.value = true
try {
const [docRes, catRes, pendRes] = await Promise.all([
api.get<any>('/documents'),
api.get<any>('/documents/categories'),
api.get<any>('/documents/pending/list')
])
documents.value = docRes.data.documents || []
categories.value = catRes.data.categories || []
pendingDocs.value = pendRes.data.documents || []
} catch (e) { console.error(e) }
loading.value = false
}
async function acknowledge(docId: string) {
try {
await api.post(`/documents/${docId}/acknowledge`, {})
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
async function createDocument() {
try {
await api.post('/documents', form.value)
showModal.value = false
form.value = { title: '', description: '', category_id: '', file_url: '', is_mandatory: false }
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">📁 Dokumente</h1>
<p class="text-gray-500">Unterlagen & Bestätigungen</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Dokument</button>
</div>
<!-- Pending Documents Alert -->
<div v-if="pendingDocs.length > 0" class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-6">
<div class="flex">
<span class="text-yellow-600 text-xl mr-3"></span>
<div>
<h3 class="font-semibold text-yellow-800">{{ pendingDocs.length }} Dokument(e) zu bestätigen</h3>
<div class="mt-2 space-y-2">
<div v-for="doc in pendingDocs" :key="doc.id" class="flex items-center justify-between bg-white p-2 rounded">
<span>{{ doc.category_icon }} {{ doc.title }}</span>
<button @click="acknowledge(doc.id)" class="text-sm text-blue-600 hover:underline">Bestätigen</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="loading" class="text-center py-12">Laden...</div>
<div v-else-if="documents.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">📄</p>
<p>Keine Dokumente vorhanden</p>
</div>
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="doc in documents" :key="doc.id" class="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow">
<div class="flex items-start space-x-3">
<span class="text-2xl">{{ doc.category_icon || '📄' }}</span>
<div class="flex-1 min-w-0">
<h3 class="font-semibold truncate">{{ doc.title }}</h3>
<p class="text-sm text-gray-500">{{ doc.category_name }}</p>
<p v-if="doc.description" class="text-sm text-gray-400 mt-1 line-clamp-2">{{ doc.description }}</p>
</div>
</div>
<div class="mt-3 flex items-center justify-between text-xs">
<span v-if="doc.is_mandatory" class="px-2 py-1 bg-red-100 text-red-700 rounded">Pflicht</span>
<span v-if="doc.acknowledged" class="text-green-600"> Bestätigt</span>
<span v-else-if="doc.is_mandatory" class="text-orange-600">Ausstehend</span>
</div>
<a v-if="doc.file_url" :href="doc.file_url" target="_blank"
class="mt-3 block text-center text-sm text-blue-600 hover:underline">
📥 Herunterladen
</a>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-bold mb-4">Neues Dokument</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Titel *</label>
<input v-model="form.title" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Kategorie</label>
<select v-model="form.category_id" class="input">
<option value="">-- Wählen --</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.icon }} {{ c.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Datei-URL</label>
<input v-model="form.file_url" class="input" placeholder="https://..." />
</div>
<div>
<label class="block text-sm font-medium mb-1">Beschreibung</label>
<textarea v-model="form.description" rows="2" class="input"></textarea>
</div>
<label class="flex items-center">
<input v-model="form.is_mandatory" type="checkbox" class="mr-2" />
<span class="text-sm">Pflichtdokument (Bestätigung erforderlich)</span>
</label>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createDocument" class="btn btn-primary">Erstellen</button>
</div>
</div>
</div>
</div>
</template>

134
src/views/IncidentsView.vue Normal file
View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const incidents = ref<any[]>([])
const categories = ref<any[]>([])
const showModal = ref(false)
const form = ref({ title: '', description: '', category_id: '', severity: 2, object_id: '' })
onMounted(async () => {
await loadData()
})
async function loadData() {
loading.value = true
try {
const [incRes, catRes] = await Promise.all([
api.get<any>('/incidents'),
api.get<any>('/incidents/categories')
])
incidents.value = incRes.data.incidents || []
categories.value = catRes.data.categories || []
} catch (e) { console.error(e) }
loading.value = false
}
async function createIncident() {
try {
await api.post('/incidents', form.value)
showModal.value = false
form.value = { title: '', description: '', category_id: '', severity: 2, object_id: '' }
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
function severityColor(s: number): string {
if (s >= 4) return 'bg-red-100 text-red-700'
if (s >= 3) return 'bg-orange-100 text-orange-700'
return 'bg-yellow-100 text-yellow-700'
}
function statusBadge(s: string): { text: string, class: string } {
const map: Record<string, any> = {
open: { text: 'Offen', class: 'bg-red-100 text-red-700' },
in_progress: { text: 'In Bearbeitung', class: 'bg-yellow-100 text-yellow-700' },
resolved: { text: 'Gelöst', class: 'bg-green-100 text-green-700' },
closed: { text: 'Geschlossen', class: 'bg-gray-100 text-gray-700' }
}
return map[s] || { text: s, class: 'bg-gray-100' }
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">🚨 Vorfallberichte</h1>
<p class="text-gray-500">Incidents dokumentieren und verfolgen</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Vorfall melden</button>
</div>
<div v-if="loading" class="text-center py-12">Laden...</div>
<div v-else-if="incidents.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4"></p>
<p>Keine Vorfälle gemeldet</p>
</div>
<div v-else class="space-y-4">
<div v-for="inc in incidents" :key="inc.id" class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<div class="flex items-start space-x-3">
<span class="text-2xl">{{ inc.category_icon || '📋' }}</span>
<div>
<h3 class="font-semibold">{{ inc.title }}</h3>
<p class="text-sm text-gray-500">{{ inc.category_name }} · {{ inc.object_name || 'Ohne Objekt' }}</p>
<p class="text-sm text-gray-400 mt-1">{{ new Date(inc.occurred_at).toLocaleString('de-DE') }}</p>
</div>
</div>
<div class="flex flex-col items-end space-y-1">
<span :class="['px-2 py-1 text-xs rounded', statusBadge(inc.status).class]">
{{ statusBadge(inc.status).text }}
</span>
<span :class="['px-2 py-1 text-xs rounded', severityColor(inc.severity)]">
Stufe {{ inc.severity }}
</span>
</div>
</div>
<p v-if="inc.description" class="mt-3 text-sm text-gray-600">{{ inc.description }}</p>
<div class="mt-3 flex items-center text-xs text-gray-500">
<span>Gemeldet von {{ inc.reporter_first }} {{ inc.reporter_last }}</span>
<span v-if="inc.attachment_count" class="ml-4">📎 {{ inc.attachment_count }} Anhänge</span>
</div>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
<h2 class="text-xl font-bold mb-4">Vorfall melden</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Titel *</label>
<input v-model="form.title" class="input" placeholder="Kurze Beschreibung" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Kategorie</label>
<select v-model="form.category_id" class="input">
<option value="">-- Wählen --</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.icon }} {{ c.name }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Schweregrad (1-5)</label>
<input v-model.number="form.severity" type="range" min="1" max="5" class="w-full" />
<div class="flex justify-between text-xs text-gray-500">
<span>Gering</span><span>Kritisch</span>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Beschreibung</label>
<textarea v-model="form.description" rows="3" class="input" placeholder="Details..."></textarea>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createIncident" class="btn btn-primary">Melden</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,718 +1,189 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { api } from '@/api'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
interface ObjectType {
key: string
name: string
icon: string
}
interface SecObject {
id: number
name: string
short_name?: string
object_number?: string
object_type: string
street?: string
house_number?: string
postal_code?: string
city?: string
phone?: string
email?: string
description?: string
customer_name?: string
status: string
contact_count: number
checkpoint_count: number
}
const loading = ref(true)
const objects = ref<SecObject[]>([])
const objectTypes = ref<ObjectType[]>([])
const objects = ref<any[]>([])
const selectedObject = ref<any>(null)
// Filters
const searchQuery = ref('')
const filterType = ref('')
const filterStatus = ref('active')
// Modal
const showModal = ref(false)
const showDetailModal = ref(false)
const editingObject = ref<any>(null)
const formData = ref({
name: '',
short_name: '',
object_number: '',
object_type: 'other',
street: '',
house_number: '',
postal_code: '',
city: '',
phone: '',
email: '',
description: '',
access_info: '',
parking_info: '',
customer_name: '',
size_sqm: null as number | null,
floors: null as number | null
})
const saving = ref(false)
const form = ref({ name: '', address: '', city: '', postal_code: '', customer_id: '' })
// Contact form
const showContactModal = ref(false)
const contactForm = ref({
name: '',
role: '',
company: '',
phone: '',
mobile: '',
email: '',
availability: '',
is_primary: false,
is_emergency: false,
notes: ''
})
onMounted(async () => { await loadData() })
// Instruction form
const showInstructionModal = ref(false)
const instructionForm = ref({
title: '',
category: 'general',
content: '',
is_critical: false
})
onMounted(async () => {
await Promise.all([
loadObjects(),
loadObjectTypes()
])
async function loadData() {
loading.value = true
try {
const res = await api.get<any>('/objects')
objects.value = res.data.objects || []
} catch (e) { console.error(e) }
loading.value = false
})
}
async function loadObjects() {
async function loadObject(id: string) {
try {
const params = new URLSearchParams()
if (filterStatus.value) params.append('status', filterStatus.value)
if (filterType.value) params.append('type', filterType.value)
if (searchQuery.value) params.append('search', searchQuery.value)
objects.value = await api.get(`/objects?${params}`)
} catch (e) {
console.error('Failed to load objects:', e)
}
const res = await api.get<any>(`/objects/${id}`)
selectedObject.value = res.data
} catch (e) { console.error(e) }
}
async function loadObjectTypes() {
async function createObject() {
try {
objectTypes.value = await api.get('/objects/types')
} catch (e) {
console.error('Failed to load types:', e)
}
}
async function loadObjectDetail(id: number) {
try {
selectedObject.value = await api.get(`/objects/${id}`)
showDetailModal.value = true
} catch (e) {
console.error('Failed to load object:', e)
}
}
const filteredObjects = computed(() => {
let result = [...objects.value]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(o =>
o.name.toLowerCase().includes(query) ||
o.city?.toLowerCase().includes(query) ||
o.object_number?.toLowerCase().includes(query) ||
o.customer_name?.toLowerCase().includes(query)
)
}
return result
})
function openAddModal() {
editingObject.value = null
formData.value = {
name: '',
short_name: '',
object_number: '',
object_type: 'other',
street: '',
house_number: '',
postal_code: '',
city: '',
phone: '',
email: '',
description: '',
access_info: '',
parking_info: '',
customer_name: '',
size_sqm: null,
floors: null
}
showModal.value = true
}
function openEditModal(obj: SecObject) {
editingObject.value = obj
formData.value = { ...obj } as any
showModal.value = true
}
async function saveObject() {
if (!formData.value.name) return
saving.value = true
try {
if (editingObject.value) {
await api.put(`/objects/${editingObject.value.id}`, formData.value)
} else {
await api.post('/objects', formData.value)
}
await api.post('/objects', form.value)
showModal.value = false
await loadObjects()
} catch (e) {
console.error('Failed to save:', e)
} finally {
saving.value = false
form.value = { name: '', address: '', city: '', postal_code: '', customer_id: '' }
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
function statusBadge(s: string) {
const map: Record<string, any> = {
active: { text: 'Aktiv', class: 'bg-green-100 text-green-700', icon: '🟢' },
inactive: { text: 'Inaktiv', class: 'bg-gray-100 text-gray-700', icon: '⚪' },
pending: { text: 'Ausstehend', class: 'bg-yellow-100 text-yellow-700', icon: '🟡' }
}
}
async function deleteObject(id: number) {
if (!confirm('Objekt wirklich archivieren?')) return
try {
await api.delete(`/objects/${id}`)
await loadObjects()
} catch (e) {
console.error('Failed to delete:', e)
}
}
async function addContact() {
if (!contactForm.value.name || !selectedObject.value) return
try {
await api.post(`/objects/${selectedObject.value.id}/contacts`, contactForm.value)
await loadObjectDetail(selectedObject.value.id)
showContactModal.value = false
contactForm.value = { name: '', role: '', company: '', phone: '', mobile: '', email: '', availability: '', is_primary: false, is_emergency: false, notes: '' }
} catch (e) {
console.error('Failed to add contact:', e)
}
}
async function deleteContact(contactId: number) {
if (!confirm('Kontakt löschen?')) return
try {
await api.delete(`/objects/${selectedObject.value.id}/contacts/${contactId}`)
await loadObjectDetail(selectedObject.value.id)
} catch (e) {
console.error('Failed to delete:', e)
}
}
async function addInstruction() {
if (!instructionForm.value.title || !instructionForm.value.content || !selectedObject.value) return
try {
await api.post(`/objects/${selectedObject.value.id}/instructions`, instructionForm.value)
await loadObjectDetail(selectedObject.value.id)
showInstructionModal.value = false
instructionForm.value = { title: '', category: 'general', content: '', is_critical: false }
} catch (e) {
console.error('Failed to add instruction:', e)
}
}
async function deleteInstruction(instructionId: number) {
if (!confirm('Dienstanweisung löschen?')) return
try {
await api.delete(`/objects/${selectedObject.value.id}/instructions/${instructionId}`)
await loadObjectDetail(selectedObject.value.id)
} catch (e) {
console.error('Failed to delete:', e)
}
}
function getTypeIcon(type: string): string {
const t = objectTypes.value.find(t => t.key === type)
return t?.icon || '📍'
}
function getTypeName(type: string): string {
const t = objectTypes.value.find(t => t.key === type)
return t?.name || type
}
function getInstructionCategoryName(cat: string): string {
const cats: Record<string, string> = {
general: 'Allgemein',
patrol: 'Rundgang',
emergency: 'Notfall',
access: 'Zugang',
reporting: 'Meldewesen'
}
return cats[cat] || cat
return map[s] || { text: s, class: 'bg-gray-100', icon: '' }
}
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">🏢 Objekte</h1>
<button
v-if="authStore.canManageUsers"
@click="openAddModal"
class="btn btn-primary"
>
Neues Objekt
</button>
</div>
<!-- Filters -->
<div class="card">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium mb-1">🔍 Suche</label>
<input
v-model="searchQuery"
type="text"
class="input"
placeholder="Name, Stadt, Kunde..."
@input="loadObjects"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">🏷 Typ</label>
<select v-model="filterType" class="input" @change="loadObjects">
<option value="">Alle Typen</option>
<option v-for="type in objectTypes" :key="type.key" :value="type.key">
{{ type.icon }} {{ type.name }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">📊 Status</label>
<select v-model="filterStatus" class="input" @change="loadObjects">
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
<option value="archived">Archiviert</option>
<option value="all">Alle</option>
</select>
</div>
<div class="flex items-end">
<p class="text-sm text-gray-500">
{{ filteredObjects.length }} Objekte
</p>
</div>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">🏢 Objektverwaltung</h1>
<p class="text-gray-500">Standorte, Kontakte & Anweisungen</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Objekt</button>
</div>
<!-- Loading -->
<div v-if="loading" class="text-center py-12">
<div class="animate-spin text-4xl"></div>
</div>
<div class="flex gap-6">
<!-- Object List -->
<div class="w-1/2">
<div v-if="loading" class="text-center py-12">Laden...</div>
<!-- Objects Grid -->
<div v-else-if="filteredObjects.length === 0" class="card text-center py-12 text-gray-500">
Keine Objekte gefunden
</div>
<div v-else-if="objects.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">🏢</p>
<p>Keine Objekte vorhanden</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="obj in filteredObjects"
:key="obj.id"
class="card hover:shadow-lg transition-all cursor-pointer"
@click="loadObjectDetail(obj.id)"
>
<div class="flex items-start gap-4">
<div class="text-4xl">{{ getTypeIcon(obj.object_type) }}</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white truncate">
{{ obj.name }}
</h3>
<p v-if="obj.city" class="text-sm text-gray-500">
📍 {{ obj.postal_code }} {{ obj.city }}
</p>
<p v-if="obj.customer_name" class="text-sm text-gray-500">
<div v-else class="space-y-2">
<div v-for="obj in objects" :key="obj.id"
@click="loadObject(obj.id)"
:class="['bg-white rounded-lg shadow p-4 cursor-pointer hover:shadow-md transition-shadow',
selectedObject?.id === obj.id ? 'ring-2 ring-blue-500' : '']">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold">{{ obj.name }}</h3>
<p class="text-sm text-gray-500">{{ obj.address }}</p>
<p class="text-sm text-gray-400">{{ obj.postal_code }} {{ obj.city }}</p>
</div>
<span :class="['px-2 py-1 text-xs rounded', statusBadge(obj.status).class]">
{{ statusBadge(obj.status).icon }} {{ statusBadge(obj.status).text }}
</span>
</div>
<div v-if="obj.customer_name" class="mt-2 text-xs text-gray-500">
👤 {{ obj.customer_name }}
</p>
<div class="flex gap-2 mt-2">
<span class="text-xs bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{{ getTypeName(obj.object_type) }}
</span>
<span v-if="obj.contact_count" class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded">
👥 {{ obj.contact_count }}
</span>
<span v-if="obj.checkpoint_count" class="text-xs bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded">
📍 {{ obj.checkpoint_count }} CP
</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div v-if="authStore.canManageUsers" class="flex gap-2 mt-4 pt-3 border-t dark:border-gray-700" @click.stop>
<button @click="openEditModal(obj)" class="text-sm text-blue-600 hover:underline">
Bearbeiten
</button>
<button @click="deleteObject(obj.id)" class="text-sm text-red-600 hover:underline">
🗑 Archivieren
</button>
</div>
</div>
</div>
<!-- Detail Modal -->
<div v-if="showDetailModal && selectedObject" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<!-- Header -->
<div class="flex items-start justify-between mb-6">
<div class="flex items-center gap-4">
<span class="text-4xl">{{ getTypeIcon(selectedObject.object_type) }}</span>
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ selectedObject.name }}</h2>
<p v-if="selectedObject.city" class="text-gray-500">
{{ selectedObject.street }} {{ selectedObject.house_number }},
{{ selectedObject.postal_code }} {{ selectedObject.city }}
</p>
</div>
</div>
<button @click="showDetailModal = false" class="text-2xl hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded">
</button>
</div>
<!-- Object Detail -->
<div class="w-1/2">
<div v-if="!selectedObject" class="bg-gray-50 rounded-lg p-12 text-center text-gray-400">
<p>Wähle ein Objekt aus</p>
</div>
<!-- Info Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div v-if="selectedObject.phone" class="bg-gray-50 dark:bg-gray-700 p-3 rounded">
<p class="text-xs text-gray-500">📞 Telefon</p>
<p class="font-medium">{{ selectedObject.phone }}</p>
</div>
<div v-if="selectedObject.email" class="bg-gray-50 dark:bg-gray-700 p-3 rounded">
<p class="text-xs text-gray-500">📧 E-Mail</p>
<p class="font-medium truncate">{{ selectedObject.email }}</p>
</div>
<div v-if="selectedObject.customer_name" class="bg-gray-50 dark:bg-gray-700 p-3 rounded">
<p class="text-xs text-gray-500">👤 Kunde</p>
<p class="font-medium">{{ selectedObject.customer_name }}</p>
</div>
<div v-if="selectedObject.object_number" class="bg-gray-50 dark:bg-gray-700 p-3 rounded">
<p class="text-xs text-gray-500"># Objektnummer</p>
<p class="font-medium">{{ selectedObject.object_number }}</p>
</div>
</div>
<div v-else class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-bold mb-1">{{ selectedObject.name }}</h2>
<p class="text-gray-500 mb-4">{{ selectedObject.address }}, {{ selectedObject.postal_code }} {{ selectedObject.city }}</p>
<!-- Description -->
<div v-if="selectedObject.description" class="mb-6">
<h3 class="font-semibold mb-2">📝 Beschreibung</h3>
<p class="text-gray-600 dark:text-gray-300 whitespace-pre-wrap">{{ selectedObject.description }}</p>
</div>
<!-- Access Info -->
<div v-if="selectedObject.access_info || selectedObject.parking_info" class="grid grid-cols-2 gap-4 mb-6">
<div v-if="selectedObject.access_info" class="bg-yellow-50 dark:bg-yellow-900/20 p-4 rounded">
<h3 class="font-semibold mb-2">🔑 Zugang</h3>
<p class="text-sm whitespace-pre-wrap">{{ selectedObject.access_info }}</p>
</div>
<div v-if="selectedObject.parking_info" class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded">
<h3 class="font-semibold mb-2">🅿 Parken</h3>
<p class="text-sm whitespace-pre-wrap">{{ selectedObject.parking_info }}</p>
</div>
</div>
<!-- Contacts -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold">👥 Ansprechpartner</h3>
<button v-if="authStore.canManageUsers" @click="showContactModal = true" class="text-sm text-blue-600 hover:underline">
Hinzufügen
</button>
</div>
<div v-if="!selectedObject.contacts?.length" class="text-gray-500 text-sm">
Keine Kontakte hinterlegt
</div>
<div v-else class="grid gap-2">
<div
v-for="contact in selectedObject.contacts"
:key="contact.id"
class="flex items-center justify-between bg-gray-50 dark:bg-gray-700 p-3 rounded"
>
<div>
<p class="font-medium">
{{ contact.name }}
<span v-if="contact.is_primary" class="text-xs bg-blue-500 text-white px-1 rounded ml-1">Haupt</span>
<span v-if="contact.is_emergency" class="text-xs bg-red-500 text-white px-1 rounded ml-1">Notfall</span>
</p>
<p v-if="contact.role" class="text-sm text-gray-500">{{ contact.role }}</p>
<p v-if="contact.phone || contact.mobile" class="text-sm">
📞 {{ contact.phone || contact.mobile }}
</p>
</div>
<button v-if="authStore.canManageUsers" @click="deleteContact(contact.id)" class="text-red-500 hover:text-red-700">
🗑
</button>
</div>
</div>
</div>
<!-- Instructions -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold">📋 Dienstanweisungen</h3>
<button v-if="authStore.canManageUsers" @click="showInstructionModal = true" class="text-sm text-blue-600 hover:underline">
Hinzufügen
</button>
</div>
<div v-if="!selectedObject.instructions?.length" class="text-gray-500 text-sm">
Keine Dienstanweisungen hinterlegt
</div>
<div v-else class="space-y-3">
<div
v-for="instr in selectedObject.instructions"
:key="instr.id"
:class="['p-4 rounded border-l-4', instr.is_critical ? 'border-red-500 bg-red-50 dark:bg-red-900/20' : 'border-gray-300 bg-gray-50 dark:bg-gray-700']"
>
<div class="flex items-start justify-between">
<div>
<p class="font-medium">
{{ instr.title }}
<span class="text-xs text-gray-500 ml-2">{{ getInstructionCategoryName(instr.category) }}</span>
</p>
<p class="text-sm mt-1 whitespace-pre-wrap text-gray-600 dark:text-gray-300">{{ instr.content }}</p>
</div>
<button v-if="authStore.canManageUsers" @click="deleteInstruction(instr.id)" class="text-red-500 hover:text-red-700 ml-2">
🗑
</button>
<div class="space-y-6">
<!-- Contacts -->
<div>
<h3 class="text-sm font-semibold text-gray-500 mb-2 flex items-center">
📞 Ansprechpartner
<span class="ml-2 text-xs bg-gray-100 px-2 py-0.5 rounded">{{ selectedObject.contacts?.length || 0 }}</span>
</h3>
<div v-if="selectedObject.contacts?.length" class="space-y-2">
<div v-for="c in selectedObject.contacts" :key="c.id" class="p-2 bg-gray-50 rounded text-sm">
<div class="font-medium">{{ c.name }} <span class="text-gray-400 font-normal">{{ c.role }}</span></div>
<div class="text-gray-600">📞 {{ c.phone }} · 📧 {{ c.email }}</div>
</div>
</div>
<p v-else class="text-sm text-gray-400">Keine Kontakte hinterlegt</p>
</div>
</div>
<!-- Checkpoints -->
<div v-if="selectedObject.checkpoints?.length" class="mb-6">
<h3 class="font-semibold mb-3">📍 Kontrollpunkte</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="cp in selectedObject.checkpoints"
:key="cp.id"
class="bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-3 py-1 rounded text-sm"
>
{{ cp.name }}
</span>
<!-- Instructions -->
<div>
<h3 class="text-sm font-semibold text-gray-500 mb-2 flex items-center">
📋 Anweisungen
<span class="ml-2 text-xs bg-gray-100 px-2 py-0.5 rounded">{{ selectedObject.instructions?.length || 0 }}</span>
</h3>
<div v-if="selectedObject.instructions?.length" class="space-y-2">
<div v-for="ins in selectedObject.instructions" :key="ins.id"
:class="['p-2 rounded text-sm', ins.priority === 'critical' ? 'bg-red-50 border-l-4 border-red-500' :
ins.priority === 'high' ? 'bg-orange-50 border-l-4 border-orange-500' : 'bg-gray-50']">
<div class="font-medium">{{ ins.title }}</div>
<p class="text-gray-600">{{ ins.content }}</p>
</div>
</div>
<p v-else class="text-sm text-gray-400">Keine Anweisungen hinterlegt</p>
</div>
</div>
<!-- Close Button -->
<div class="flex justify-end pt-4 border-t dark:border-gray-700">
<button @click="showDetailModal = false" class="btn">
Schließen
</button>
<!-- Documents -->
<div v-if="selectedObject.documents?.length">
<h3 class="text-sm font-semibold text-gray-500 mb-2">📁 Dokumente</h3>
<div class="flex flex-wrap gap-2">
<a v-for="d in selectedObject.documents" :key="d.id"
:href="d.file_url" target="_blank"
class="px-2 py-1 bg-blue-50 text-blue-700 rounded text-sm hover:bg-blue-100">
{{ d.file_name || 'Dokument' }}
</a>
</div>
</div>
<!-- Checkpoints -->
<div v-if="selectedObject.checkpoints?.length">
<h3 class="text-sm font-semibold text-gray-500 mb-2">📍 Checkpoints</h3>
<div class="flex flex-wrap gap-2">
<span v-for="cp in selectedObject.checkpoints" :key="cp.id"
class="px-2 py-1 bg-gray-100 rounded text-sm">
{{ cp.name }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Add/Edit Object Modal -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6">
<h2 class="text-xl font-semibold mb-4">
{{ editingObject ? '✏️ Objekt bearbeiten' : ' Neues Objekt' }}
</h2>
<form @submit.prevent="saveObject" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium mb-1">Name *</label>
<input v-model="formData.name" type="text" class="input" required />
</div>
<div>
<label class="block text-sm font-medium mb-1">Kurzname</label>
<input v-model="formData.short_name" type="text" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Objektnummer</label>
<input v-model="formData.object_number" type="text" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Typ</label>
<select v-model="formData.object_type" class="input">
<option v-for="type in objectTypes" :key="type.key" :value="type.key">
{{ type.icon }} {{ type.name }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Kunde</label>
<input v-model="formData.customer_name" type="text" class="input" />
</div>
</div>
<hr class="dark:border-gray-700">
<div class="grid grid-cols-4 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium mb-1">Straße</label>
<input v-model="formData.street" type="text" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Nr.</label>
<input v-model="formData.house_number" type="text" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">PLZ</label>
<input v-model="formData.postal_code" type="text" class="input" />
</div>
<div class="col-span-2">
<label class="block text-sm font-medium mb-1">Stadt</label>
<input v-model="formData.city" type="text" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Telefon</label>
<input v-model="formData.phone" type="tel" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">E-Mail</label>
<input v-model="formData.email" type="email" class="input" />
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-bold mb-4">Neues Objekt</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Name *</label>
<input v-model="form.name" class="input" placeholder="z.B. Hauptgebäude Musterstraße" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Adresse</label>
<input v-model="form.address" class="input" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Beschreibung</label>
<textarea v-model="formData.description" class="input" rows="2"></textarea>
<label class="block text-sm font-medium mb-1">PLZ</label>
<input v-model="form.postal_code" class="input" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">🔑 Zugangsinfos</label>
<textarea v-model="formData.access_info" class="input" rows="2" placeholder="Schlüssel, Codes..."></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1">🅿 Parkhinweise</label>
<textarea v-model="formData.parking_info" class="input" rows="2"></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1">Stadt</label>
<input v-model="form.city" class="input" />
</div>
<div class="flex gap-3 pt-4">
<button type="button" @click="showModal = false" class="btn flex-1">
Abbrechen
</button>
<button type="submit" :disabled="saving" class="btn btn-primary flex-1">
{{ saving ? 'Speichern...' : 'Speichern' }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Add Contact Modal -->
<div v-if="showContactModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
<div class="p-6">
<h2 class="text-xl font-semibold mb-4">👤 Kontakt hinzufügen</h2>
<form @submit.prevent="addContact" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Name *</label>
<input v-model="contactForm.name" type="text" class="input" required />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Rolle</label>
<input v-model="contactForm.role" type="text" class="input" placeholder="z.B. Hausmeister" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Firma</label>
<input v-model="contactForm.company" type="text" class="input" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Telefon</label>
<input v-model="contactForm.phone" type="tel" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Mobil</label>
<input v-model="contactForm.mobile" type="tel" class="input" />
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">E-Mail</label>
<input v-model="contactForm.email" type="email" class="input" />
</div>
<div class="flex gap-4">
<label class="flex items-center gap-2">
<input v-model="contactForm.is_primary" type="checkbox" class="rounded" />
<span class="text-sm">Hauptkontakt</span>
</label>
<label class="flex items-center gap-2">
<input v-model="contactForm.is_emergency" type="checkbox" class="rounded" />
<span class="text-sm">Notfallkontakt</span>
</label>
</div>
<div class="flex gap-3">
<button type="button" @click="showContactModal = false" class="btn flex-1">Abbrechen</button>
<button type="submit" class="btn btn-primary flex-1">Hinzufügen</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add Instruction Modal -->
<div v-if="showInstructionModal" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
<div class="p-6">
<h2 class="text-xl font-semibold mb-4">📋 Dienstanweisung hinzufügen</h2>
<form @submit.prevent="addInstruction" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Titel *</label>
<input v-model="instructionForm.title" type="text" class="input" required />
</div>
<div>
<label class="block text-sm font-medium mb-1">Kategorie</label>
<select v-model="instructionForm.category" class="input">
<option value="general">Allgemein</option>
<option value="patrol">Rundgang</option>
<option value="emergency">Notfall</option>
<option value="access">Zugang</option>
<option value="reporting">Meldewesen</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Inhalt *</label>
<textarea v-model="instructionForm.content" class="input" rows="4" required></textarea>
</div>
<label class="flex items-center gap-2">
<input v-model="instructionForm.is_critical" type="checkbox" class="rounded" />
<span class="text-sm text-red-600"> Als kritisch markieren</span>
</label>
<div class="flex gap-3">
<button type="button" @click="showInstructionModal = false" class="btn flex-1">Abbrechen</button>
<button type="submit" class="btn btn-primary flex-1">Hinzufügen</button>
</div>
</form>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createObject" class="btn btn-primary">Erstellen</button>
</div>
</div>
</div>

142
src/views/PatrolsView.vue Normal file
View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const checkpoints = ref<any[]>([])
const routes = ref<any[]>([])
const logs = ref<any[]>([])
const activeTab = ref<'checkpoints' | 'routes' | 'logs'>('checkpoints')
onMounted(async () => {
await loadData()
})
async function loadData() {
loading.value = true
try {
const [cpRes, routesRes, logsRes] = await Promise.all([
api.get<any>('/patrols/checkpoints'),
api.get<any>('/patrols/routes'),
api.get<any>('/patrols/logs')
])
checkpoints.value = cpRes.data.checkpoints || []
routes.value = routesRes.data.routes || []
logs.value = logsRes.data.logs || []
} catch (e) {
console.error(e)
}
loading.value = false
}
function formatTime(ts: string): string {
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">📍 Wächterkontrolle</h1>
<p class="text-gray-500">Checkpoints, Routen & Rundgänge</p>
</div>
</div>
<!-- Tabs -->
<div class="flex space-x-1 border-b mb-6">
<button @click="activeTab = 'checkpoints'"
:class="['px-4 py-2 font-medium border-b-2 -mb-px', activeTab === 'checkpoints' ? 'border-blue-600 text-blue-600' : 'border-transparent']">
Checkpoints ({{ checkpoints.length }})
</button>
<button @click="activeTab = 'routes'"
:class="['px-4 py-2 font-medium border-b-2 -mb-px', activeTab === 'routes' ? 'border-blue-600 text-blue-600' : 'border-transparent']">
Routen ({{ routes.length }})
</button>
<button @click="activeTab = 'logs'"
:class="['px-4 py-2 font-medium border-b-2 -mb-px', activeTab === 'logs' ? 'border-blue-600 text-blue-600' : 'border-transparent']">
Rundgänge
</button>
</div>
<div v-if="loading" class="text-center py-12">Laden...</div>
<!-- Checkpoints -->
<div v-else-if="activeTab === 'checkpoints'">
<div v-if="checkpoints.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">📍</p>
<p>Keine Checkpoints vorhanden</p>
<p class="text-sm mt-2">Erstelle Checkpoints mit QR-Codes für Rundgänge</p>
</div>
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="cp in checkpoints" :key="cp.id" class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold">{{ cp.name }}</h3>
<p class="text-sm text-gray-500">{{ cp.object_name }}</p>
<p class="text-xs text-gray-400 mt-1">{{ cp.location_description }}</p>
</div>
<span class="text-2xl">{{ cp.checkpoint_type === 'nfc' ? '📶' : '📱' }}</span>
</div>
<div class="mt-3 p-2 bg-gray-100 rounded text-xs font-mono break-all">
{{ cp.code }}
</div>
</div>
</div>
</div>
<!-- Routes -->
<div v-else-if="activeTab === 'routes'">
<div v-if="routes.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">🗺</p>
<p>Keine Routen definiert</p>
</div>
<div v-else class="space-y-4">
<div v-for="route in routes" :key="route.id" class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold">{{ route.name }}</h3>
<p class="text-sm text-gray-500">{{ route.object_name }}</p>
</div>
<span :class="['px-2 py-1 text-xs rounded', route.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100']">
{{ route.is_active ? 'Aktiv' : 'Inaktiv' }}
</span>
</div>
<div class="mt-3 flex gap-4 text-sm text-gray-600">
<span>📍 {{ route.checkpoint_count || 0 }} Checkpoints</span>
<span v-if="route.time_limit_minutes"> Max {{ route.time_limit_minutes }} Min</span>
<span v-if="route.interval_minutes">🔄 Alle {{ route.interval_minutes }} Min</span>
</div>
</div>
</div>
</div>
<!-- Logs -->
<div v-else-if="activeTab === 'logs'">
<div v-if="logs.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">📋</p>
<p>Keine Rundgänge heute</p>
</div>
<div v-else class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Zeit</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Mitarbeiter</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Checkpoint</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Objekt</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="log in logs" :key="log.id" class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm">{{ formatTime(log.scanned_at) }}</td>
<td class="px-4 py-3 text-sm">{{ log.first_name }} {{ log.last_name }}</td>
<td class="px-4 py-3 text-sm">{{ log.checkpoint_name }}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{ log.object_name }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>

162
src/views/ShiftsView.vue Normal file
View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const shifts = ref<any[]>([])
const assignments = ref<any[]>([])
const showModal = ref(false)
const currentWeek = ref(new Date())
const form = ref({
name: '',
start_time: '06:00',
end_time: '14:00',
break_minutes: 30,
color: '#3B82F6',
is_night_shift: false
})
onMounted(async () => {
await loadData()
})
async function loadData() {
loading.value = true
try {
const [shiftsRes, assignRes] = await Promise.all([
api.get<any>('/shifts/definitions'),
api.get<any>(`/shifts/assignments?start=${formatDate(getWeekStart())}&end=${formatDate(getWeekEnd())}`)
])
shifts.value = shiftsRes.data.shifts || []
assignments.value = assignRes.data.assignments || []
} catch (e) {
console.error(e)
}
loading.value = false
}
function getWeekStart(): Date {
const d = new Date(currentWeek.value)
d.setDate(d.getDate() - d.getDay() + 1)
return d
}
function getWeekEnd(): Date {
const d = new Date(getWeekStart())
d.setDate(d.getDate() + 6)
return d
}
function formatDate(d: Date): string {
return d.toISOString().split('T')[0]
}
const weekDays = computed(() => {
const days = []
const start = getWeekStart()
for (let i = 0; i < 7; i++) {
const d = new Date(start)
d.setDate(d.getDate() + i)
days.push({ date: formatDate(d), label: d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit' }) })
}
return days
})
async function createShift() {
try {
await api.post('/shifts/definitions', form.value)
showModal.value = false
form.value = { name: '', start_time: '06:00', end_time: '14:00', break_minutes: 30, color: '#3B82F6', is_night_shift: false }
await loadData()
} catch (e: any) {
alert('Fehler: ' + e.message)
}
}
function getAssignmentsForDay(date: string) {
return assignments.value.filter(a => a.date === date)
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">📅 Schichtplanung</h1>
<p class="text-gray-500">Dienstpläne verwalten</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Schicht definieren</button>
</div>
<!-- Shift Definitions -->
<div class="bg-white rounded-lg shadow p-4 mb-6">
<h2 class="font-semibold mb-3">Schicht-Typen</h2>
<div class="flex flex-wrap gap-2">
<div v-for="s in shifts" :key="s.id"
:style="{ backgroundColor: s.color + '20', borderColor: s.color }"
class="px-3 py-1 rounded-full border text-sm">
{{ s.name }} ({{ s.start_time?.slice(0,5) }} - {{ s.end_time?.slice(0,5) }})
</div>
<div v-if="shifts.length === 0" class="text-gray-500">Keine Schichten definiert</div>
</div>
</div>
<!-- Week View -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="flex items-center justify-between p-4 border-b">
<button @click="currentWeek.setDate(currentWeek.getDate() - 7); loadData()" class="text-gray-600 hover:text-gray-900"> Vorherige</button>
<span class="font-semibold">{{ getWeekStart().toLocaleDateString('de-DE') }} - {{ getWeekEnd().toLocaleDateString('de-DE') }}</span>
<button @click="currentWeek.setDate(currentWeek.getDate() + 7); loadData()" class="text-gray-600 hover:text-gray-900">Nächste </button>
</div>
<div class="grid grid-cols-7 divide-x">
<div v-for="day in weekDays" :key="day.date" class="min-h-32 p-2">
<div class="text-xs font-semibold text-gray-500 mb-2">{{ day.label }}</div>
<div v-for="a in getAssignmentsForDay(day.date)" :key="a.id"
:style="{ backgroundColor: a.color + '40' }"
class="text-xs p-1 rounded mb-1">
{{ a.first_name }} {{ a.last_name?.charAt(0) }}.
</div>
</div>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-bold mb-4">Neue Schicht definieren</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Name</label>
<input v-model="form.name" class="input" placeholder="z.B. Frühschicht" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Beginn</label>
<input v-model="form.start_time" type="time" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Ende</label>
<input v-model="form.end_time" type="time" class="input" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Pause (Min.)</label>
<input v-model.number="form.break_minutes" type="number" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Farbe</label>
<input v-model="form.color" type="color" class="w-full h-10 rounded" />
</div>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createShift" class="btn btn-primary">Erstellen</button>
</div>
</div>
</div>
</div>
</template>

122
src/views/VehiclesView.vue Normal file
View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api'
const loading = ref(true)
const vehicles = ref<any[]>([])
const showModal = ref(false)
const form = ref({ license_plate: '', brand: '', model: '', year: 2024, color: '', fuel_type: 'diesel' })
onMounted(async () => { await loadData() })
async function loadData() {
loading.value = true
try {
const res = await api.get<any>('/vehicles')
vehicles.value = res.data.vehicles || []
} catch (e) { console.error(e) }
loading.value = false
}
async function createVehicle() {
try {
await api.post('/vehicles', form.value)
showModal.value = false
form.value = { license_plate: '', brand: '', model: '', year: 2024, color: '', fuel_type: 'diesel' }
await loadData()
} catch (e: any) { alert('Fehler: ' + e.message) }
}
function statusBadge(s: string): { text: string, class: string, icon: string } {
const map: Record<string, any> = {
available: { text: 'Verfügbar', class: 'bg-green-100 text-green-700', icon: '✅' },
in_use: { text: 'Im Einsatz', class: 'bg-blue-100 text-blue-700', icon: '🚗' },
maintenance: { text: 'Wartung', class: 'bg-yellow-100 text-yellow-700', icon: '🔧' },
retired: { text: 'Stillgelegt', class: 'bg-gray-100 text-gray-700', icon: '⛔' }
}
return map[s] || { text: s, class: 'bg-gray-100', icon: '' }
}
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">🚗 Fahrzeuge</h1>
<p class="text-gray-500">Fuhrpark verwalten</p>
</div>
<button @click="showModal = true" class="btn btn-primary">+ Fahrzeug</button>
</div>
<div v-if="loading" class="text-center py-12">Laden...</div>
<div v-else-if="vehicles.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
<p class="text-4xl mb-4">🚗</p>
<p>Keine Fahrzeuge vorhanden</p>
</div>
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="v in vehicles" :key="v.id" class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold text-lg">{{ v.license_plate }}</h3>
<p class="text-gray-600">{{ v.brand }} {{ v.model }}</p>
<p v-if="v.year" class="text-sm text-gray-400">Baujahr {{ v.year }}</p>
</div>
<span :class="['px-2 py-1 text-xs rounded', statusBadge(v.status).class]">
{{ statusBadge(v.status).icon }} {{ statusBadge(v.status).text }}
</span>
</div>
<div class="mt-4 grid grid-cols-2 gap-2 text-sm">
<div><span class="text-gray-500">KM-Stand:</span> {{ v.current_mileage?.toLocaleString() || '-' }}</div>
<div><span class="text-gray-500">Kraftstoff:</span> {{ v.fuel_type }}</div>
<div v-if="v.tuev_expires" :class="['col-span-2', new Date(v.tuev_expires) < new Date() ? 'text-red-600' : '']">
TÜV: {{ new Date(v.tuev_expires).toLocaleDateString('de-DE') }}
</div>
</div>
</div>
</div>
<!-- Modal -->
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-bold mb-4">Neues Fahrzeug</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Kennzeichen *</label>
<input v-model="form.license_plate" class="input" placeholder="B-AB 1234" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Marke</label>
<input v-model="form.brand" class="input" placeholder="VW" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Modell</label>
<input v-model="form.model" class="input" placeholder="Passat" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Baujahr</label>
<input v-model.number="form.year" type="number" class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Kraftstoff</label>
<select v-model="form.fuel_type" class="input">
<option value="diesel">Diesel</option>
<option value="petrol">Benzin</option>
<option value="electric">Elektro</option>
<option value="hybrid">Hybrid</option>
</select>
</div>
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createVehicle" class="btn btn-primary">Erstellen</button>
</div>
</div>
</div>
</div>
</template>