Files
RAPPORT/src/views/Dashboard.jsx
T
karim 00f07d76f6 Rapport 0.6 — Initial Public Release
Sicherheits-Hardening
- Passwort-Hashing mit PBKDF2 (SHA-256, 100k Iterationen) inkl. transparenter
  Migration bestehender Klartext-Passwörter beim ersten Login
- Login Brute-Force-Schutz (5 Fehlversuche → 60s Lockout), Constant-Time-Compare,
  Mindestpasswortlänge 8 Zeichen
- HTML-Sanitizer für Brieftexte (Allowlist, entfernt javascript:/data:/vbscript:-URLs,
  Event-Handler, Script-Tags; rel=noopener für target=_blank)
- Datenexport entfernt Legacy-Klartextpasswörter (Hashes bleiben)
- Kryptografische IDs via crypto.randomUUID statt Math.random
- sessionStorage speichert keine Credentials mehr

GUI & Performance
- Code-Splitting pro View via React.lazy + Suspense (Initial-Bundle 86 KB gzipped)
- swissqrbill als lokale Dependency — QR-Rechnungen offline-fähig
- Spesenbelege (Bild/PDF) direkt in der Tageserfassung mit Bildkomprimierung
- Avatar-Upload: 256px-Skalierung + JPEG-Kompression, Typprüfung
- Über-Rapport-Modal, einheitliche Bearbeiten-Icons, Pinnwand-Kategorien als Pills

Bug-Fixes
- Auto-überfällig-Routine läuft nur noch einmal pro Tag (verhindert Re-Render-Loop)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 01:16:26 +02:00

763 lines
50 KiB
React
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useRef, useEffect } from "react";
import { SIA_PHASES, DASHBOARD_WIDGETS } from "../constants.js";
import { formatCHF, formatDate, formatHours, migrateDashboardLayout, widgetsToRows, generateId } from "../utils.js";
const HEIGHT_OPTS = [
{ v: 0, l: "Auto" },
{ v: 160, l: "S" },
{ v: 280, l: "M" },
{ v: 420, l: "L" },
];
function deepCloneLayout(layout) {
return (layout || []).map(r => ({ ...r, widgets: [...r.widgets] }));
}
export default function Dashboard({ data, setView, currentUser, saveAll }) {
const today = new Date().toISOString().slice(0, 10);
const thisMonth = today.slice(0, 7);
const thisYear = today.slice(0, 4);
const lastMonth = (() => { const d = new Date(); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 7); })();
// ─── Layout resolution ─────────────────────────────────────────────
const myUser = (data.users || []).find(u => u.id === currentUser?.id);
const myRole = (data.appRoles || []).find(r => r.id === (currentUser?.appRoleId || myUser?.appRoleId));
const myTpl = (data.dashboardTemplates || []).find(t => t.id === myRole?.dashboardTemplateId);
const roleLayout = myTpl
? migrateDashboardLayout(myTpl.layout)
: migrateDashboardLayout(myRole?.dashboardWidgets) // fallback for old data
|| widgetsToRows(DASHBOARD_WIDGETS.map(w => w.id));
const savedLayout = myUser?.dashboardWidgets ? migrateDashboardLayout(myUser.dashboardWidgets) : null;
const [editMode, setEditMode] = useState(false);
const [layout, setLayout] = useState([]);
const [dragOver, setDragOver] = useState(null);
const [addPopoverRowId, setAddPopoverRowId] = useState(null);
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false);
const [newPublicName, setNewPublicName] = useState("");
const [newPrivateName, setNewPrivateName] = useState("");
const dragRef = useRef(null);
const activeLayout = editMode ? layout : (savedLayout || roleLayout);
// ─── Data ──────────────────────────────────────────────────────────
const activeProjects = data.projects.filter(p => p.status === "aktiv");
const projMins = id => data.timeEntries.filter(e => e.projectId === id).reduce((s, e) => s + (e.minutes || 0), 0);
const monthMins = data.timeEntries.filter(e => (e.date||"").startsWith(thisMonth)).reduce((s,e)=>s+(e.minutes||0),0);
const lastMoMins = data.timeEntries.filter(e => (e.date||"").startsWith(lastMonth)).reduce((s,e)=>s+(e.minutes||0),0);
const myEmpId = currentUser?.employeeId;
const myMonthMins = data.timeEntries.filter(e => (e.date||"").startsWith(thisMonth) && e.employeeId===myEmpId).reduce((s,e)=>s+(e.minutes||0),0);
const recentTime = [...data.timeEntries].sort((a,b)=>(b.date||"").localeCompare(a.date||"")).slice(0,6);
const myRecentTime = [...data.timeEntries].filter(e=>e.employeeId===myEmpId).sort((a,b)=>(b.date||"").localeCompare(a.date||"")).slice(0,6);
const openInvoices = data.invoices.filter(i => i.status==="gesendet"||i.status==="überfällig");
const overdueInvoices = data.invoices.filter(i => i.status==="überfällig");
const openAmount = openInvoices.reduce((s,i)=>s+(i.total||0),0);
const paidThisYear = data.invoices.filter(i=>i.status==="bezahlt"&&(i.date||"").startsWith(thisYear)).reduce((s,i)=>s+(i.sub||0),0);
const pendingQuotes = (data.quotes||[]).filter(q=>q.status==="gesendet");
const expiredQuotes = (data.quotes||[]).filter(q=>q.status==="gesendet"&&q.validUntil&&q.validUntil<today);
const unbilledProjects = activeProjects.map(p=>{
const mins = data.timeEntries.filter(e=>e.projectId===p.id&&!e.invoiceId).reduce((s,e)=>s+(e.minutes||0),0);
return {...p, unbilledMins:mins, unbilledAmt:(mins/60)*(p.hourlyRate||0)};
}).filter(p=>p.unbilledMins>0&&(p.billingType||p.type)==="stundensatz").sort((a,b)=>b.unbilledMins-a.unbilledMins);
const totalUnbilled = unbilledProjects.reduce((s,p)=>s+p.unbilledMins,0);
const last6Months = Array.from({length:6},(_,i)=>{const d=new Date();d.setMonth(d.getMonth()-(5-i));return d.toISOString().slice(0,7);});
const monthlyRevenue = last6Months.map(m=>({m,paid:data.invoices.filter(i=>i.status==="bezahlt"&&(i.date||"").startsWith(m)).reduce((s,i)=>s+(i.sub||0),0)}));
const maxRev = Math.max(...monthlyRevenue.map(m=>m.paid),1);
// ─── Permission ────────────────────────────────────────────────────
const canSaveTemplate = !myRole || myRole.permissions === null || (myRole.permissions||[]).includes("dashboard-vorlage");
// ─── Edit mode ─────────────────────────────────────────────────────
const enterEdit = () => { setLayout(deepCloneLayout(savedLayout || roleLayout)); setEditMode(true); };
const cancelEdit = () => { setEditMode(false); setAddPopoverRowId(null); setSaveTemplateOpen(false); setNewPublicName(""); setNewPrivateName(""); };
const saveEdit = () => {
if (currentUser && saveAll) {
const users = (data.users||[]).map(u => u.id===currentUser.id ? {...u, dashboardWidgets: layout} : u);
saveAll({ ...data, users });
}
setEditMode(false); setAddPopoverRowId(null); setSaveTemplateOpen(false); setNewPublicName(""); setNewPrivateName("");
};
const loadTemplate = tplId => {
const tpl = (data.dashboardTemplates||[]).find(t=>t.id===tplId);
if (tpl?.layout) setLayout(deepCloneLayout(migrateDashboardLayout(tpl.layout)));
};
const saveAsTemplate = (tplId) => {
if (!saveAll) return;
const dashboardTemplates = (data.dashboardTemplates||[]).map(t => t.id===tplId ? {...t, layout} : t);
saveAll({ ...data, dashboardTemplates });
setSaveTemplateOpen(false);
};
const createTemplate = (name, isPublic) => {
if (!name.trim() || !saveAll) return;
const tpl = { id: generateId(), name: name.trim(), isPublic, layout, ...(!isPublic ? { createdBy: currentUser?.id } : {}) };
saveAll({ ...data, dashboardTemplates: [...(data.dashboardTemplates||[]), tpl] });
setSaveTemplateOpen(false);
if (isPublic) setNewPublicName(""); else setNewPrivateName("");
};
// Add widget to row, moving it out of any other row it's already in
const addWidgetExclusive = (rowId, wid) => {
setLayout(l => l.map(r => {
if (r.id === rowId) return { ...r, widgets: r.widgets.includes(wid) ? r.widgets : [...r.widgets, wid] };
return { ...r, widgets: r.widgets.filter(w => w !== wid) };
}));
setAddPopoverRowId(null);
};
// Close popovers on outside click
useEffect(() => {
if (!addPopoverRowId && !saveTemplateOpen) return;
const close = e => { if (!e.target.closest("[data-popover]")) { setAddPopoverRowId(null); setSaveTemplateOpen(false); } };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [addPopoverRowId, saveTemplateOpen]);
// ─── Row operations ────────────────────────────────────────────────
const updateRow = (rowId, patch) => setLayout(l => l.map(r => r.id===rowId ? {...r,...patch} : r));
const addRow = (afterId) => {
const row = { id: generateId(), cols: 2, minH: 0, widgets: [] };
setLayout(l => {
const i = l.findIndex(r=>r.id===afterId);
const next = [...l];
next.splice(i+1, 0, row);
return next;
});
};
const deleteRow = rowId => setLayout(l => l.filter(r=>r.id!==rowId));
const moveRow = (rowId, dir) => setLayout(l => {
const i = l.findIndex(r=>r.id===rowId);
const j = i + dir;
if (j<0||j>=l.length) return l;
const next=[...l];
[next[i],next[j]]=[next[j],next[i]];
return next;
});
const addWidgetToRow = (rowId, wid) =>
setLayout(l => l.map(r => r.id===rowId ? {...r, widgets:[...r.widgets,wid]} : r));
const removeWidgetFromRow = (rowId, wid) =>
setLayout(l => l.map(r => r.id===rowId ? {...r, widgets:r.widgets.filter(w=>w!==wid)} : r));
// ─── Drag & Drop ───────────────────────────────────────────────────
const handleDragStart = (fromRowId, widgetId) => {
dragRef.current = { fromRowId, widgetId };
};
const handleDragEnd = () => {
dragRef.current = null;
setDragOver(null);
};
const handleDrop = (toRowId, beforeWidgetId) => {
const src = dragRef.current;
if (!src) return;
const { fromRowId, widgetId } = src;
setLayout(l => {
const next = deepCloneLayout(l);
const fromRow = next.find(r=>r.id===fromRowId);
const toRow = next.find(r=>r.id===toRowId);
if (!fromRow||!toRow) return l;
fromRow.widgets = fromRow.widgets.filter(w=>w!==widgetId);
if (beforeWidgetId) {
const idx = toRow.widgets.indexOf(beforeWidgetId);
toRow.widgets.splice(idx<0 ? toRow.widgets.length : idx, 0, widgetId);
} else {
if (!toRow.widgets.includes(widgetId)) toRow.widgets.push(widgetId);
}
return next;
});
dragRef.current = null;
setDragOver(null);
};
// Styles
const ctrlBtn = "pill";
const activeCtrlBtn = "pill active";
// Widget content renderers
const wContent = {
"kpi-projekte": () => <KpiCard label="AKTIVE PROJEKTE" value={activeProjects.length} color="#b07848" go="projects" setView={!editMode?setView:undefined} sub={`${data.projects.filter(p=>p.status==="abgeschlossen").length} abgeschlossen`} />,
"kpi-stunden": () => <KpiCard label={`STUNDEN ${new Date().toLocaleString("de-CH",{month:"long"}).toUpperCase()}`} value={formatHours(myEmpId?myMonthMins:monthMins)} color="#2d6a4f" go="time" setView={!editMode?setView:undefined} sub={!myEmpId&&lastMoMins>0?`Vormonat: ${formatHours(lastMoMins)}`:undefined} />,
"kpi-ausstehend": () => <KpiCard label="AUSSTEHEND" value={formatCHF(openAmount)} color={overdueInvoices.length>0?"#8a1a1a":"#7a6a00"} go="invoices" setView={!editMode?setView:undefined} sub={overdueInvoices.length>0?`${overdueInvoices.length} überfällig`:`${openInvoices.length} gesendet`} />,
"kpi-umsatz": () => <KpiCard label="UMSATZ DIESES JAHR" value={formatCHF(paidThisYear)} color="#2d6a4f" go="invoices" setView={!editMode?setView:undefined} sub="bezahlt (netto)" />,
"warnungen": () => {
const has = overdueInvoices.length>0||expiredQuotes.length>0;
if (!editMode&&!has) return null;
return has ? (
<div style={{display:"flex",gap:12,flexWrap:"wrap",height:"100%",alignItems:"stretch"}}>
{overdueInvoices.length>0&&<div onClick={!editMode?()=>setView("invoices"):undefined} style={{flex:1,minWidth:180,padding:"12px 16px",background:"#fff3f3",border:"1.5px solid #e0b0b0",borderRadius:8,cursor:editMode?"default":"pointer",display:"flex",alignItems:"center",gap:12}}>
<div style={{fontSize:18}}></div>
<div><div style={{fontSize:12,fontWeight:600,color:"#8a1a1a"}}>{overdueInvoices.length} überfällige Rechnung{overdueInvoices.length>1?"en":""}</div><div style={{fontSize:11,color:"#b5621e",marginTop:2}}>{formatCHF(overdueInvoices.reduce((s,i)=>s+i.total,0))} ausstehend</div></div>
</div>}
{expiredQuotes.length>0&&<div onClick={!editMode?()=>setView("quotes"):undefined} style={{flex:1,minWidth:180,padding:"12px 16px",background:"#fffbe8",border:"1.5px solid #e0d090",borderRadius:8,cursor:editMode?"default":"pointer",display:"flex",alignItems:"center",gap:12}}>
<div style={{fontSize:18}}></div>
<div><div style={{fontSize:12,fontWeight:600,color:"#7a6a00"}}>{expiredQuotes.length} Offerte{expiredQuotes.length>1?"n":""} abgelaufen</div><div style={{fontSize:11,color:"#888",marginTop:2}}>Gültigkeit überschritten</div></div>
</div>}
</div>
) : <div className="card" style={{fontSize:12,color:"var(--text5)",fontStyle:"italic"}}>Warnungen (keine aktuellen)</div>;
},
"aktive-projekte": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>AKTIVE PROJEKTE</div>
<button onClick={()=>setView("projects")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Alle </button>
</div>
{activeProjects.length===0?<div style={{fontSize:13,color:"var(--text5)",textAlign:"center",padding:"20px 0"}}>Keine aktiven Projekte</div>:activeProjects.slice(0,6).map(p=>{
const used=projMins(p.id),budget=p.budgetHours||0,pct=budget>0?Math.min((used/60)/budget,1):0,over=budget>0&&(used/60)>budget;
const cl=(data.persons||[]).filter(x=>x.isAuftraggeber).find(c=>c.id===p.clientId);
return (<div key={p.id} style={{marginBottom:14,paddingBottom:14,borderBottom:"1px solid var(--border2)"}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:4}}>
<div><div style={{fontSize:12,fontWeight:500,color:"var(--text)"}}>{p.name}</div>{cl&&<div style={{fontSize:10,color:"var(--text4)",marginTop:1}}>{cl.name}</div>}</div>
<div style={{textAlign:"right",fontSize:11,color:over?"#8a1a1a":"var(--text4)",whiteSpace:"nowrap"}}>{formatHours(used)}{budget>0?` / ${budget}h`:""}</div>
</div>
{budget>0&&<div style={{height:3,background:"var(--border2)",borderRadius:2}}><div style={{width:`${pct*100}%`,height:"100%",background:over?"#8a1a1a":pct>0.8?"#b5621e":"#2d6a4f",borderRadius:2,transition:"width 0.3s"}}/></div>}
</div>);
})}
</div>
),
"unverrechnete-stunden": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:16}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>UNVERRECHNETE STUNDEN</div>
<button onClick={()=>setView("invoices")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}></button>
</div>
{totalUnbilled===0?<div style={{fontSize:13,color:"#2d6a4f",textAlign:"center",padding:"20px 0"}}> Alles verrechnet</div>:<>
<div style={{fontSize:22,fontFamily:"'Playfair Display',serif",fontWeight:700,color:"#b5621e",marginBottom:2}}>{formatHours(totalUnbilled)}</div>
<div style={{fontSize:11,color:"var(--text5)",marginBottom:16}}> {formatCHF(unbilledProjects.reduce((s,p)=>s+p.unbilledAmt,0))}</div>
{unbilledProjects.slice(0,5).map(p=><div key={p.id} style={{display:"flex",justifyContent:"space-between",padding:"5px 0",borderBottom:"1px solid var(--border2)",fontSize:12}}><span style={{color:"var(--text2)",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:"60%"}}>{p.name}</span><span style={{color:"#b5621e",flexShrink:0}}>{formatHours(p.unbilledMins)}</span></div>)}
</>}
</div>
),
"umsatz-sparkline": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:14}}>UMSATZ LETZTE 6 MONATE</div>
<div style={{display:"flex",alignItems:"flex-end",gap:6,height:60}}>
{monthlyRevenue.map(({m,paid})=>(
<div key={m} style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",gap:4}}>
<div style={{width:"100%",height:paid>0?`${Math.max((paid/maxRev)*54,4)}px`:"2px",background:m===thisMonth?"#b07848":paid>0?"#2d6a4f":"var(--border2)",borderRadius:"2px 2px 0 0",transition:"height 0.3s"}} title={formatCHF(paid)}/>
<div style={{fontSize:8,color:m===thisMonth?"#b07848":"var(--text5)"}}>{new Date(m+"-01").toLocaleString("de-CH",{month:"short"})}</div>
</div>
))}
</div>
</div>
),
"offene-offerten": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>OFFENE OFFERTEN</div>
<button onClick={()=>setView("quotes")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Alle </button>
</div>
{pendingQuotes.length===0?<div style={{fontSize:12,color:"var(--text5)"}}>Keine pendenten Offerten</div>:pendingQuotes.slice(0,5).map(q=>{
const cl=(data.persons||[]).filter(p=>p.isAuftraggeber).find(c=>c.id===q.clientId);
const expired=q.validUntil&&q.validUntil<today;
return (<div key={q.id} style={{display:"flex",justifyContent:"space-between",padding:"5px 0",borderBottom:"1px solid var(--border2)",fontSize:12}}>
<div><div style={{color:expired?"#b5621e":"var(--text2)"}}>{q.number}</div>{cl&&<div style={{fontSize:10,color:"var(--text5)"}}>{cl.name}</div>}</div>
<div style={{textAlign:"right",flexShrink:0}}><div style={{color:"var(--text2)",fontWeight:500}}>{formatCHF(q.total)}</div>{expired&&<div style={{fontSize:9,color:"#b5621e"}}>abgelaufen</div>}</div>
</div>);
})}
</div>
),
"letzte-zeiteintraege": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>LETZTE ZEITEINTRÄGE</div>
<button onClick={()=>setView("time")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Zeiterfassung </button>
</div>
<TimeTable entries={recentTime} data={data}/>
</div>
),
"meine-zeiteintraege": () => (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>MEINE ZEITEINTRÄGE</div>
<button onClick={()=>setView("time")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Zeiterfassung </button>
</div>
<TimeTable entries={myRecentTime} data={data}/>
</div>
),
"meine-projekte": () => {
const myProjects = (data.projects||[]).filter(p=>p.status==="aktiv"&&(p.internalMembers||[]).includes(myEmpId));
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>MEINE PROJEKTE</div>
<button onClick={()=>!editMode&&setView("projects")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Alle </button>
</div>
{myProjects.length===0
? <div style={{fontSize:13,color:"var(--text5)",textAlign:"center",padding:"20px 0"}}>Keinen Projekten zugewiesen</div>
: myProjects.map(p=>{
const myMins=data.timeEntries.filter(e=>e.projectId===p.id&&e.employeeId===myEmpId).reduce((s,e)=>s+(e.minutes||0),0);
const cl=(data.persons||[]).find(c=>c.id===p.clientId);
return (
<div key={p.id} style={{marginBottom:12,paddingBottom:12,borderBottom:"1px solid var(--border2)"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"baseline"}}>
<div>
<div style={{fontSize:12,fontWeight:500,color:"var(--text)"}}>{p.name}</div>
{cl&&<div style={{fontSize:10,color:"var(--text4)",marginTop:1}}>{cl.name}</div>}
</div>
<div style={{fontSize:11,color:"var(--text3)",flexShrink:0,marginLeft:8}}>{formatHours(myMins)}</div>
</div>
</div>
);
})
}
</div>
);
},
"meine-ferien": () => {
const myEmp=(data.employees||[]).find(e=>e.id===myEmpId);
if(!myEmpId||!myEmp) return <div className="card" style={{height:"100%",boxSizing:"border-box",display:"flex",alignItems:"center",justifyContent:"center",fontSize:12,color:"var(--text5)"}}>Kein Mitarbeiterprofil verknüpft</div>;
const anspruchTage=(myEmp.ferienWochen||5)*5;
const approvedEntries=(data.ferienEntries||[]).filter(f=>f.employeeId===myEmpId&&(f.status==="approved"||!f.status)&&(f.dateFrom||"").startsWith(thisYear));
const countWorkdays=(from,to)=>{
let n=0;const d=new Date(from);const end=new Date(to);
while(d<=end){const dow=d.getDay();if(dow!==0&&dow!==6)n++;d.setDate(d.getDate()+1);}
return n;
};
const bezogenTage=approvedEntries.reduce((s,f)=>s+countWorkdays(f.dateFrom,f.dateTo),0);
const restTage=anspruchTage-bezogenTage;
const pct=Math.min(bezogenTage/anspruchTage,1);
const upcoming=(data.ferienEntries||[]).filter(f=>f.employeeId===myEmpId&&(f.status==="approved"||!f.status)&&f.dateFrom>today).slice(0,2);
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:14}}>FERIENSTAND {thisYear}</div>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:6}}>
<div style={{textAlign:"center"}}>
<div style={{fontSize:22,fontFamily:"'Playfair Display',serif",fontWeight:700,color:"#2d6a4f"}}>{restTage}</div>
<div style={{fontSize:10,color:"var(--text4)"}}>Verbleibend</div>
</div>
<div style={{textAlign:"center"}}>
<div style={{fontSize:22,fontFamily:"'Playfair Display',serif",fontWeight:700,color:"var(--text)"}}>{bezogenTage}</div>
<div style={{fontSize:10,color:"var(--text4)"}}>Bezogen</div>
</div>
<div style={{textAlign:"center"}}>
<div style={{fontSize:22,fontFamily:"'Playfair Display',serif",fontWeight:700,color:"var(--text3)"}}>{anspruchTage}</div>
<div style={{fontSize:10,color:"var(--text4)"}}>Anspruch</div>
</div>
</div>
<div style={{height:4,background:"var(--border2)",borderRadius:2,margin:"12px 0 14px"}}>
<div style={{width:`${pct*100}%`,height:"100%",background:restTage<5?"#8a1a1a":"#2d6a4f",borderRadius:2,transition:"width 0.4s"}}/>
</div>
{upcoming.length>0&&<>
<div style={{fontSize:9,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:6}}>NÄCHSTE FERIEN</div>
{upcoming.map(f=><div key={f.id} style={{fontSize:11,color:"var(--text2)",marginBottom:3}}>{formatDate(f.dateFrom)} {formatDate(f.dateTo)}</div>)}
</>}
</div>
);
},
"ueberstunden": () => {
const myEmp=(data.employees||[]).find(e=>e.id===myEmpId);
if(!myEmpId||!myEmp) return <div className="card" style={{height:"100%",boxSizing:"border-box",display:"flex",alignItems:"center",justifyContent:"center",fontSize:12,color:"var(--text5)"}}>Kein Mitarbeiterprofil verknüpft</div>;
const pensum=(myEmp.pensum||100)/100;
const tagessollH=((myEmp.wochenstunden||35)*pensum)/5;
const startOfYear=`${thisYear}-01-01`;
const fts=data.feiertage||[];
// Respect eintrittsdatum: only count from when the employee actually started
const effectiveYearStart=myEmp.eintrittsdatum&&myEmp.eintrittsdatum>startOfYear?myEmp.eintrittsdatum:startOfYear;
const todayD=new Date(today);
let workdays=0;const d=new Date(effectiveYearStart);
while(d<=todayD){const dow=d.getDay();const ds=d.toISOString().slice(0,10);const ft=fts.find(f=>f.date===ds);const isFt=ft&&(ft.stundenDelta===0||ft.stundenDelta===null||ft.stundenDelta===undefined);if(dow!==0&&dow!==6&&!isFt)workdays++;d.setDate(d.getDate()+1);}
const sollMin=workdays*tagessollH*60;
const istMin=data.timeEntries.filter(e=>e.employeeId===myEmpId&&(e.date||"")>=effectiveYearStart&&(e.date||"")<=today).reduce((s,e)=>s+(e.minutes||0),0);
const deltaMin=istMin-sollMin;
const isPos=deltaMin>=0;
// This month — also respect eintrittsdatum
const effectiveMonthStart=myEmp.eintrittsdatum&&myEmp.eintrittsdatum>`${thisMonth}-01`?myEmp.eintrittsdatum:`${thisMonth}-01`;
const sollMonthMin=(() => {
let wd=0;const me=new Date(new Date(`${thisMonth}-01`).getFullYear(),new Date(`${thisMonth}-01`).getMonth()+1,0);
const end=me<todayD?me:todayD;const dd=new Date(effectiveMonthStart);
while(dd<=end){const dow=dd.getDay();const ds=dd.toISOString().slice(0,10);const ft=fts.find(f=>f.date===ds);const isFt=ft&&(ft.stundenDelta===0||ft.stundenDelta===null||ft.stundenDelta===undefined);if(dow!==0&&dow!==6&&!isFt)wd++;dd.setDate(dd.getDate()+1);}
return wd*tagessollH*60;
})();
const istMonthMin=data.timeEntries.filter(e=>e.employeeId===myEmpId&&(e.date||"").startsWith(thisMonth)).reduce((s,e)=>s+(e.minutes||0),0);
const deltaMonth=istMonthMin-sollMonthMin;
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:14}}>STUNDENSALDO</div>
<div style={{marginBottom:14}}>
<div style={{fontSize:10,color:"var(--text4)",marginBottom:4}}>JAHRESSALDO {thisYear}</div>
<div style={{fontSize:26,fontFamily:"'Playfair Display',serif",fontWeight:700,color:isPos?"#2d6a4f":"#8a1a1a"}}>
{isPos?"+":""}{formatHours(Math.abs(deltaMin))}
<span style={{fontSize:13,fontWeight:400,marginLeft:4}}>{isPos?"Überstunden":"Minusstunden"}</span>
</div>
<div style={{fontSize:11,color:"var(--text4)",marginTop:2}}>{formatHours(istMin)} von {formatHours(sollMin)} Soll</div>
</div>
<div style={{paddingTop:12,borderTop:"1px solid var(--border2)"}}>
<div style={{fontSize:10,color:"var(--text4)",marginBottom:4}}>DIESER MONAT</div>
<div style={{fontSize:14,fontWeight:600,color:deltaMonth>=0?"#2d6a4f":"#8a1a1a"}}>
{deltaMonth>=0?"+":""}{formatHours(Math.abs(deltaMonth))}
</div>
<div style={{fontSize:11,color:"var(--text4)"}}>{formatHours(istMonthMin)} von {formatHours(sollMonthMin)} Soll</div>
</div>
</div>
);
},
"stunden-woche": () => {
const myEmp=(data.employees||[]).find(e=>e.id===myEmpId);
const pensum=(myEmp?.pensum||100)/100;
const sollH=(myEmp?.wochenstunden||35)*pensum;
const getMonday=(dateStr)=>{const d=new Date(dateStr);const day=d.getDay();d.setDate(d.getDate()-(day===0?6:day-1));return d;};
const monday=getMonday(today);
const weeks=Array.from({length:5},(_,i)=>{
const mon=new Date(monday);mon.setDate(mon.getDate()-(4-i)*7);
const sun=new Date(mon);sun.setDate(sun.getDate()+6);
const monStr=mon.toISOString().slice(0,10);
const sunStr=sun.toISOString().slice(0,10);
const isCurrent=i===4;
const mins=(myEmpId?data.timeEntries.filter(e=>e.employeeId===myEmpId&&e.date>=monStr&&e.date<=sunStr):data.timeEntries.filter(e=>e.date>=monStr&&e.date<=sunStr)).reduce((s,e)=>s+(e.minutes||0),0);
const kw=(() => { const d2=new Date(mon);d2.setHours(0,0,0,0);d2.setDate(d2.getDate()+3-(d2.getDay()||7)+1); const w1=new Date(d2.getFullYear(),0,4);return 1+Math.round(((d2-w1)/86400000+((w1.getDay()||7)-1))/7); })();
return {kw,mins,isCurrent};
});
const maxMins=Math.max(...weeks.map(w=>w.mins),sollH*60,1);
const sollPct=(sollH*60)/maxMins;
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:14}}>STUNDEN PRO WOCHE</div>
<div style={{display:"flex",alignItems:"flex-end",gap:8,height:80,position:"relative"}}>
<div style={{position:"absolute",bottom:`${sollPct*100}%`,left:0,right:0,borderTop:"1px dashed var(--border3)",pointerEvents:"none"}} title={`Soll: ${sollH}h`}/>
{weeks.map(({kw,mins,isCurrent})=>{
const pct=mins/maxMins;
const over=mins>sollH*60;
return (
<div key={kw} style={{flex:1,display:"flex",flexDirection:"column",alignItems:"center",gap:3}}>
<div style={{fontSize:9,color:over?"#2d6a4f":"var(--text4)"}}>{mins>0?formatHours(mins):""}</div>
<div style={{width:"100%",height:`${Math.max(pct*72,mins>0?4:1)}px`,background:isCurrent?(over?"#2d6a4f":"#b07848"):over?"#2d6a4f33":"var(--border2)",borderRadius:"3px 3px 0 0",transition:"height 0.3s"}}/>
<div style={{fontSize:9,color:isCurrent?"var(--text2)":"var(--text5)",fontWeight:isCurrent?600:400}}>KW{kw}</div>
</div>
);
})}
</div>
<div style={{marginTop:10,fontSize:10,color:"var(--text4)"}}>
Soll: {sollH}h / Woche
</div>
</div>
);
},
"interner-blog": () => {
const posts = (data.blogPosts || []);
const recent = [...posts].sort((a,b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
return b.createdAt.localeCompare(a.createdAt);
}).slice(0, 3);
const getMonday = d => { const dd=new Date(d); dd.setDate(dd.getDate()-(dd.getDay()===0?6:dd.getDay()-1)); return dd; };
const weekStart = getMonday(new Date(today));
const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate()+6);
const feiertageWeek = (data.feiertage||[]).filter(f=>f.date>=weekStart.toISOString().slice(0,10)&&f.date<=weekEnd.toISOString().slice(0,10));
const TYPE_COLOR = { beitrag:"#1a4e8a", ankuendigung:"#b5621e", event:"#2d6a4f" };
const TYPE_LABEL = { beitrag:"Beitrag", ankuendigung:"Ankündigung", event:"Event" };
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:14}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)"}}>PINNWAND</div>
<button onClick={()=>!editMode&&setView("pinnwand")} style={{background:"none",border:"none",fontSize:11,color:"var(--text4)",cursor:"pointer",fontFamily:"inherit"}}>Alle </button>
</div>
{feiertageWeek.length>0&&(
<div style={{padding:"8px 12px",background:"#e8f5ee",border:"1px solid #a8d8b8",borderRadius:8,marginBottom:12,fontSize:11,color:"#2d6a4f",display:"flex",gap:8,alignItems:"center"}}>
<span>🎉</span>
<span><b>Feiertag diese Woche:</b> {feiertageWeek.map(f=>f.name).join(", ")}</span>
</div>
)}
{recent.length===0
? <div style={{fontSize:13,color:"var(--text5)",textAlign:"center",padding:"16px 0"}}>Keine Beiträge</div>
: recent.map(p=>(
<div key={p.id} style={{marginBottom:12,paddingBottom:12,borderBottom:"1px solid var(--border2)"}}>
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:4}}>
<span style={{fontSize:9,fontWeight:600,color:TYPE_COLOR[p.type]||"#1a4e8a",letterSpacing:"0.06em"}}>{(TYPE_LABEL[p.type]||"").toUpperCase()}</span>
{p.pinned&&<span style={{fontSize:9,color:"#b07848"}}>📌</span>}
</div>
{p.title&&<div style={{fontSize:13,fontWeight:500,color:"var(--text)",marginBottom:2}}>{p.title}</div>}
<div style={{fontSize:11,color:"var(--text3)",lineHeight:1.5,display:"-webkit-box",WebkitLineClamp:2,WebkitBoxOrient:"vertical",overflow:"hidden"}}>{p.body}</div>
<div style={{fontSize:10,color:"var(--text4)",marginTop:4}}>{p.authorName} · {new Date(p.createdAt).toLocaleDateString("de-CH")}</div>
</div>
))
}
</div>
);
},
"team-auslastung": () => {
const activeEmps=(data.employees||[]).filter(e=>e.aktiv!==false);
const pensum=e=>(e.pensum||100)/100;
const sollH=e=>((e.wochenstunden||35)*pensum(e))/5*(() => {
let wd=0;const d=new Date(`${thisMonth}-01`);const end=new Date(d.getFullYear(),d.getMonth()+1,0);const todayD=new Date(today);
const cap=end<todayD?end:todayD;const dd=new Date(d);
while(dd<=cap){const dow=dd.getDay();if(dow!==0&&dow!==6)wd++;dd.setDate(dd.getDate()+1);}
return wd;
})();
const empData=activeEmps.map(e=>{
const istMin=data.timeEntries.filter(t=>t.employeeId===e.id&&(t.date||"").startsWith(thisMonth)).reduce((s,t)=>s+(t.minutes||0),0);
const sollMin=sollH(e)*60;
const pct=sollMin>0?Math.min(istMin/sollMin,1.5):0;
return{...e,istMin,sollMin,pct};
}).sort((a,b)=>b.istMin-a.istMin);
return (
<div className="card" style={{height:"100%",boxSizing:"border-box"}}>
<div style={{fontSize:11,fontWeight:600,letterSpacing:"0.1em",color:"var(--text4)",marginBottom:14}}>TEAM-AUSLASTUNG {new Date().toLocaleString("de-CH",{month:"long"}).toUpperCase()}</div>
{empData.length===0
? <div style={{fontSize:13,color:"var(--text5)"}}>Keine Mitarbeitenden</div>
: empData.map(e=>{
const over=e.pct>1;
return (
<div key={e.id} style={{marginBottom:10}}>
<div style={{display:"flex",justifyContent:"space-between",marginBottom:3}}>
<div style={{fontSize:12,color:"var(--text)"}}>{e.name}</div>
<div style={{fontSize:11,color:over?"#2d6a4f":"var(--text4)",fontWeight:over?600:400}}>{formatHours(e.istMin)}{e.sollMin>0?` / ${formatHours(e.sollMin)}`:""}</div>
</div>
<div style={{height:3,background:"var(--border2)",borderRadius:2}}>
<div style={{width:`${Math.min(e.pct,1)*100}%`,height:"100%",background:over?"#2d6a4f":e.pct>0.8?"#b07848":"var(--border3)",borderRadius:2,transition:"width 0.3s"}}/>
</div>
</div>
);
})
}
</div>
);
},
};
// ─── Row renderer ──────────────────────────────────────────────────
const renderRow = (row, rowIdx) => {
const isDragOverRow = dragOver?.rowId === row.id && dragOver?.before === null;
const content = wContent[row.id] ? null : null; // widget lookup happens below
return (
<div key={row.id} style={{marginBottom:16}}>
{/* Row toolbar */}
{editMode && (
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:6,flexWrap:"wrap"}}>
<span style={{fontSize:10,color:"var(--text4)",letterSpacing:"0.08em"}}>SPALTEN</span>
{[1,2,3,4].map(n=>(
<button key={n} onClick={()=>updateRow(row.id,{cols:n})} className={row.cols===n?activeCtrlBtn:ctrlBtn}>{n}</button>
))}
<span style={{fontSize:10,color:"var(--text4)",letterSpacing:"0.08em",marginLeft:6}}>HÖHE</span>
{HEIGHT_OPTS.map(opt=>(
<button key={opt.v} onClick={()=>updateRow(row.id,{minH:opt.v})} className={row.minH===opt.v?activeCtrlBtn:ctrlBtn}>{opt.l}</button>
))}
<div data-popover style={{position:"relative",marginLeft:6}}>
<button onClick={()=>setAddPopoverRowId(addPopoverRowId===row.id?null:row.id)} className={addPopoverRowId===row.id?activeCtrlBtn:ctrlBtn}>+ Widget</button>
{addPopoverRowId===row.id&&(
<div style={{position:"absolute",top:"calc(100% + 4px)",left:0,zIndex:200,background:"var(--surface)",border:"1px solid var(--border)",borderRadius:8,padding:"6px 0",minWidth:230,boxShadow:"0 4px 20px rgba(0,0,0,0.15)"}}>
{DASHBOARD_WIDGETS.map(d=>{
const inThis=row.widgets.includes(d.id);
const inOther=!inThis&&layout.some(r=>r.id!==row.id&&r.widgets.includes(d.id));
return (
<button key={d.id} onClick={()=>!inThis&&addWidgetExclusive(row.id,d.id)}
style={{display:"flex",alignItems:"center",gap:8,width:"100%",padding:"7px 14px",background:"none",border:"none",color:inThis?"var(--text4)":"var(--text)",cursor:inThis?"default":"pointer",fontFamily:"inherit",fontSize:12,textAlign:"left"}}>
<span style={{width:14,textAlign:"center",fontSize:10,opacity:0.5,flexShrink:0}}>{inThis?"✓":"+"}</span>
<span style={{flex:1}}>{d.label}</span>
{inOther&&<span style={{fontSize:9,color:"var(--text5)"}}>verschieben</span>}
</button>
);
})}
</div>
)}
</div>
<div style={{marginLeft:"auto",display:"flex",gap:4}}>
{rowIdx>0&&<button onClick={()=>moveRow(row.id,-1)} className={ctrlBtn}></button>}
{rowIdx<layout.length-1&&<button onClick={()=>moveRow(row.id,1)} className={ctrlBtn}></button>}
<button onClick={()=>addRow(row.id)} className={ctrlBtn}>+ Zeile</button>
<button onClick={()=>deleteRow(row.id)} className={ctrlBtn} style={{color:"#8a1a1a"}}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</div>
</div>
)}
{/* Row grid */}
<div
style={{
display:"grid",
gridTemplateColumns:`repeat(${row.cols},1fr)`,
gap:16,
minHeight:row.minH||undefined,
alignItems:"stretch",
outline: isDragOverRow&&editMode ? "2px dashed var(--text)" : "none",
borderRadius:10,
transition:"outline 0.1s",
}}
onDragOver={editMode?e=>{e.preventDefault();setDragOver({rowId:row.id,before:null});}:undefined}
onDrop={editMode?e=>{e.preventDefault();handleDrop(row.id,null);}:undefined}
>
{row.widgets.map(wid=>{
const content = wContent[wid];
if (!content) return null;
const rendered = content();
if (rendered===null&&!editMode) return null;
const isBefore = dragOver?.rowId===row.id&&dragOver?.before===wid;
const isDragging = dragRef.current?.widgetId===wid;
return (
<div
key={wid}
draggable={editMode}
onDragStart={editMode?()=>handleDragStart(row.id,wid):undefined}
onDragEnd={editMode?handleDragEnd:undefined}
onDragOver={editMode?e=>{e.preventDefault();e.stopPropagation();setDragOver({rowId:row.id,before:wid});}:undefined}
onDrop={editMode?e=>{e.preventDefault();e.stopPropagation();handleDrop(row.id,wid);}:undefined}
style={{
position:"relative",
height:"100%",
opacity:isDragging?0.35:1,
boxShadow:isBefore?"inset 3px 0 0 var(--text)":undefined,
borderRadius:10,
cursor:editMode?"grab":"default",
transition:"opacity 0.15s",
}}
>
{rendered===null?<div className="card" style={{fontSize:12,color:"var(--text5)",fontStyle:"italic",height:"100%",boxSizing:"border-box",display:"flex",alignItems:"center",justifyContent:"center"}}>{DASHBOARD_WIDGETS.find(d=>d.id===wid)?.label}</div>:rendered}
{editMode&&(
<button
onClick={()=>removeWidgetFromRow(row.id,wid)}
className={ctrlBtn} style={{position:"absolute",top:8,right:8,zIndex:10,color:"#8a1a1a",lineHeight:1,padding:"2px 6px"}}
onMouseDown={e=>e.stopPropagation()}
>×</button>
)}
</div>
);
})}
{/* Empty slot drop target */}
{editMode&&(
<div
onDragOver={e=>{e.preventDefault();setDragOver({rowId:row.id,before:null});}}
onDrop={e=>{e.preventDefault();handleDrop(row.id,null);}}
style={{minHeight:80,border:"1.5px dashed var(--border3)",borderRadius:10,display:"flex",alignItems:"center",justifyContent:"center",color:"var(--text5)",fontSize:13}}
>
ablegen
</div>
)}
</div>
</div>
);
};
// ─── Render ────────────────────────────────────────────────────────
return (
<div>
{/* Header */}
<div style={{marginBottom:28,display:"flex",justifyContent:"space-between",alignItems:"flex-start"}}>
<div>
<h1 style={{fontFamily:"'Playfair Display',serif",fontSize:30,fontWeight:400,letterSpacing:"-0.02em",lineHeight:1.1,color:"var(--text)"}}>
{data.settings.name||"Studio"}
</h1>
<div style={{fontSize:11,color:"var(--text4)",letterSpacing:"0.1em",marginTop:6}}>
{new Date().toLocaleDateString("de-CH",{weekday:"long",day:"numeric",month:"long",year:"numeric"}).toUpperCase()}
</div>
</div>
<div style={{display:"flex",gap:8,alignItems:"center",marginTop:6,flexShrink:0}}>
{editMode?(
<>
<select defaultValue="" onChange={e=>{if(e.target.value){loadTemplate(e.target.value);e.target.value="";}}} className={ctrlBtn}>
<option value="">Vorlage laden</option>
{(data.dashboardTemplates||[]).filter(t=>t.isPublic).map(t=>(
<option key={t.id} value={t.id}>{t.name}</option>
))}
{(data.dashboardTemplates||[]).filter(t=>!t.isPublic&&t.createdBy===currentUser?.id).map(t=>(
<option key={t.id} value={t.id}>{t.name} (persönlich)</option>
))}
</select>
<div data-popover style={{position:"relative"}}>
<button onClick={()=>setSaveTemplateOpen(v=>!v)} className={saveTemplateOpen?activeCtrlBtn:ctrlBtn}>Vorlage speichern</button>
{saveTemplateOpen&&(
<div style={{position:"absolute",top:"calc(100% + 4px)",right:0,zIndex:200,background:"var(--surface)",border:"1px solid var(--border)",borderRadius:8,minWidth:240,boxShadow:"0 4px 20px rgba(0,0,0,0.15)",overflow:"hidden"}}>
{canSaveTemplate&&(<>
<div style={{padding:"8px 14px 4px",fontSize:9,color:"var(--text4)",letterSpacing:"0.1em",fontWeight:600}}>ÖFFENTLICHE VORLAGE</div>
{(data.dashboardTemplates||[]).filter(t=>t.isPublic).map(t=>(
<button key={t.id} onClick={()=>saveAsTemplate(t.id)} style={{display:"flex",justifyContent:"space-between",alignItems:"center",width:"100%",padding:"6px 14px",background:"none",border:"none",color:"var(--text)",cursor:"pointer",fontFamily:"inherit",fontSize:12,textAlign:"left"}}>
<span>{t.name}</span><span style={{fontSize:9,color:"var(--text5)"}}>überschreiben</span>
</button>
))}
<div style={{padding:"6px 14px 10px"}}>
<div style={{display:"flex",gap:5}}>
<input value={newPublicName} onChange={e=>setNewPublicName(e.target.value)} placeholder="Neue öffentliche Vorlage…"
onKeyDown={e=>e.key==="Enter"&&createTemplate(newPublicName,true)}
style={{flex:1,height:26,border:"1px solid var(--border)",borderRadius:4,padding:"0 8px",fontSize:11,background:"var(--surface)",color:"var(--text)",fontFamily:"inherit",outline:"none"}} />
<button onClick={()=>createTemplate(newPublicName,true)} className={ctrlBtn}>+</button>
</div>
</div>
<div style={{height:1,background:"var(--border)"}}/>
</>)}
<div style={{padding:"8px 14px 4px",fontSize:9,color:"var(--text4)",letterSpacing:"0.1em",fontWeight:600}}>PERSÖNLICHE VORLAGE</div>
{(data.dashboardTemplates||[]).filter(t=>!t.isPublic&&t.createdBy===currentUser?.id).map(t=>(
<button key={t.id} onClick={()=>saveAsTemplate(t.id)} style={{display:"flex",justifyContent:"space-between",alignItems:"center",width:"100%",padding:"6px 14px",background:"none",border:"none",color:"var(--text)",cursor:"pointer",fontFamily:"inherit",fontSize:12,textAlign:"left"}}>
<span>{t.name}</span><span style={{fontSize:9,color:"var(--text5)"}}>überschreiben</span>
</button>
))}
<div style={{padding:"6px 14px 10px"}}>
<div style={{display:"flex",gap:5}}>
<input value={newPrivateName} onChange={e=>setNewPrivateName(e.target.value)} placeholder="Neue persönliche Vorlage…"
onKeyDown={e=>e.key==="Enter"&&createTemplate(newPrivateName,false)}
style={{flex:1,height:26,border:"1px solid var(--border)",borderRadius:4,padding:"0 8px",fontSize:11,background:"var(--surface)",color:"var(--text)",fontFamily:"inherit",outline:"none"}} />
<button onClick={()=>createTemplate(newPrivateName,false)} className={ctrlBtn}>+</button>
</div>
</div>
</div>
)}
</div>
<button onClick={cancelEdit} className={ctrlBtn}>Abbrechen</button>
<button onClick={saveEdit} className="btn btn-primary" style={{fontSize:11}}>Speichern</button>
</>
):(
<button onClick={enterEdit} className={ctrlBtn} style={{display:"flex",alignItems:"center",gap:5}}>
<span className="material-icons" style={{fontSize:13,verticalAlign:"middle"}}>edit</span>
Anpassen
</button>
)}
</div>
</div>
{/* Edit banner */}
{editMode&&(
<div style={{padding:"10px 16px",background:"var(--bg2)",border:"1px dashed var(--border3)",borderRadius:8,marginBottom:20,fontSize:12,color:"var(--text4)"}}>
Dashboard anpassen Spaltenanzahl und Höhe pro Zeile wählen. Widgets per Drag &amp; Drop verschieben oder mit × entfernen.
</div>
)}
{/* Rows */}
{activeLayout.map((row,idx) => renderRow(row,idx))}
{/* Edit mode: add first row / add row at bottom */}
{editMode&&(
<div style={{marginTop:8}}>
<button onClick={()=>addRow(layout[layout.length-1]?.id||"")} className={ctrlBtn} style={{width:"100%",padding:"10px",textAlign:"center",borderStyle:"dashed",color:"var(--text4)"}}>
+ Neue Zeile hinzufügen
</button>
</div>
)}
</div>
);
}
function KpiCard({label,value,sub,color,go,setView}) {
return (
<div className="card"
onClick={go&&setView?()=>setView(go):undefined}
style={{borderTop:`3px solid ${color||"var(--border)"}`,cursor:go&&setView?"pointer":"default",transition:"transform 0.15s",height:"100%",boxSizing:"border-box"}}
onMouseEnter={go&&setView?e=>{e.currentTarget.style.transform="translateY(-2px)";}:undefined}
onMouseLeave={go&&setView?e=>{e.currentTarget.style.transform="";}:undefined}
>
<div style={{fontSize:10,color:"var(--text4)",letterSpacing:"0.12em",marginBottom:8}}>{label}</div>
<div style={{fontSize:24,fontFamily:"'Playfair Display',serif",fontWeight:700,color:color||"var(--text)",marginBottom:3}}>{value}</div>
{sub&&<div style={{fontSize:11,color:"var(--text5)"}}>{sub}</div>}
</div>
);
}
function TimeTable({entries,data}) {
if (!entries.length) return <div style={{fontSize:13,color:"var(--text5)",padding:"8px 0"}}>Noch keine Zeiteinträge</div>;
return (
<table>
<thead><tr><th>Datum</th><th>Projekt</th><th>Beschreibung</th><th style={{textAlign:"right"}}>Dauer</th></tr></thead>
<tbody>
{entries.map(e=>{
const proj=data.projects.find(p=>p.id===e.projectId);
const phase=SIA_PHASES.find(ph=>ph.id===e.phaseId);
return (<tr key={e.id}>
<td>{formatDate(e.date)}</td>
<td><div style={{color:"var(--text)"}}>{proj?.name||"—"}</div>{phase&&<div style={{fontSize:10,color:"var(--text5)"}}>Phase {phase.id}</div>}</td>
<td style={{color:"var(--text3)"}}>{e.description||"—"}</td>
<td style={{textAlign:"right",fontWeight:500}}>{formatHours(e.minutes)}</td>
</tr>);
})}
</tbody>
</table>
);
}