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{ 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": () => p.status==="abgeschlossen").length} abgeschlossen`} />, "kpi-stunden": () => 0?`Vormonat: ${formatHours(lastMoMins)}`:undefined} />, "kpi-ausstehend": () => 0?"#8a1a1a":"#7a6a00"} go="invoices" setView={!editMode?setView:undefined} sub={overdueInvoices.length>0?`${overdueInvoices.length} überfällig`:`${openInvoices.length} gesendet`} />, "kpi-umsatz": () => , "warnungen": () => { const has = overdueInvoices.length>0||expiredQuotes.length>0; if (!editMode&&!has) return null; return has ? (
{overdueInvoices.length>0&&
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}}>
{overdueInvoices.length} überfällige Rechnung{overdueInvoices.length>1?"en":""}
{formatCHF(overdueInvoices.reduce((s,i)=>s+i.total,0))} ausstehend
} {expiredQuotes.length>0&&
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}}>
{expiredQuotes.length} Offerte{expiredQuotes.length>1?"n":""} abgelaufen
Gültigkeit überschritten
}
) :
Warnungen (keine aktuellen)
; }, "aktive-projekte": () => (
AKTIVE PROJEKTE
{activeProjects.length===0?
Keine aktiven Projekte
: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 (
{p.name}
{cl&&
{cl.name}
}
{formatHours(used)}{budget>0?` / ${budget}h`:""}
{budget>0&&
0.8?"#b5621e":"#2d6a4f",borderRadius:2,transition:"width 0.3s"}}/>
}
); })}
), "unverrechnete-stunden": () => (
UNVERRECHNETE STUNDEN
{totalUnbilled===0?
✓ Alles verrechnet
:<>
{formatHours(totalUnbilled)}
≈ {formatCHF(unbilledProjects.reduce((s,p)=>s+p.unbilledAmt,0))}
{unbilledProjects.slice(0,5).map(p=>
{p.name}{formatHours(p.unbilledMins)}
)} }
), "umsatz-sparkline": () => (
UMSATZ LETZTE 6 MONATE
{monthlyRevenue.map(({m,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)}/>
{new Date(m+"-01").toLocaleString("de-CH",{month:"short"})}
))}
), "offene-offerten": () => (
OFFENE OFFERTEN
{pendingQuotes.length===0?
Keine pendenten Offerten
: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
{q.number}
{cl&&
{cl.name}
}
{formatCHF(q.total)}
{expired&&
abgelaufen
}
); })}
), "letzte-zeiteintraege": () => (
LETZTE ZEITEINTRÄGE
), "meine-zeiteintraege": () => (
MEINE ZEITEINTRÄGE
), "meine-projekte": () => { const myProjects = (data.projects||[]).filter(p=>p.status==="aktiv"&&(p.internalMembers||[]).includes(myEmpId)); return (
MEINE PROJEKTE
{myProjects.length===0 ?
Keinen Projekten zugewiesen
: 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 (
{p.name}
{cl&&
{cl.name}
}
{formatHours(myMins)}
); }) }
); }, "meine-ferien": () => { const myEmp=(data.employees||[]).find(e=>e.id===myEmpId); if(!myEmpId||!myEmp) return
Kein Mitarbeiterprofil verknüpft
; 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 (
FERIENSTAND {thisYear}
{restTage}
Verbleibend
{bezogenTage}
Bezogen
{anspruchTage}
Anspruch
{upcoming.length>0&&<>
NÄCHSTE FERIEN
{upcoming.map(f=>
{formatDate(f.dateFrom)} – {formatDate(f.dateTo)}
)} }
); }, "ueberstunden": () => { const myEmp=(data.employees||[]).find(e=>e.id===myEmpId); if(!myEmpId||!myEmp) return
Kein Mitarbeiterprofil verknüpft
; 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=mef.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 (
STUNDENSALDO
JAHRESSALDO {thisYear}
{isPos?"+":""}{formatHours(Math.abs(deltaMin))} {isPos?"Überstunden":"Minusstunden"}
{formatHours(istMin)} von {formatHours(sollMin)} Soll
DIESER MONAT
=0?"#2d6a4f":"#8a1a1a"}}> {deltaMonth>=0?"+":""}{formatHours(Math.abs(deltaMonth))}
{formatHours(istMonthMin)} von {formatHours(sollMonthMin)} Soll
); }, "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 (
STUNDEN PRO WOCHE
{weeks.map(({kw,mins,isCurrent})=>{ const pct=mins/maxMins; const over=mins>sollH*60; return (
{mins>0?formatHours(mins):""}
0?4:1)}px`,background:isCurrent?(over?"#2d6a4f":"#b07848"):over?"#2d6a4f33":"var(--border2)",borderRadius:"3px 3px 0 0",transition:"height 0.3s"}}/>
KW{kw}
); })}
— Soll: {sollH}h / Woche
); }, "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 (
PINNWAND
{feiertageWeek.length>0&&(
🎉 Feiertag diese Woche: {feiertageWeek.map(f=>f.name).join(", ")}
)} {recent.length===0 ?
Keine Beiträge
: recent.map(p=>(
{(TYPE_LABEL[p.type]||"").toUpperCase()} {p.pinned&&📌}
{p.title&&
{p.title}
}
{p.body}
{p.authorName} · {new Date(p.createdAt).toLocaleDateString("de-CH")}
)) }
); }, "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{ 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 (
TEAM-AUSLASTUNG {new Date().toLocaleString("de-CH",{month:"long"}).toUpperCase()}
{empData.length===0 ?
Keine Mitarbeitenden
: empData.map(e=>{ const over=e.pct>1; return (
{e.name}
{formatHours(e.istMin)}{e.sollMin>0?` / ${formatHours(e.sollMin)}`:""}
0.8?"#b07848":"var(--border3)",borderRadius:2,transition:"width 0.3s"}}/>
); }) }
); }, }; // ─── 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 (
{/* Row toolbar */} {editMode && (
SPALTEN {[1,2,3,4].map(n=>( ))} HÖHE {HEIGHT_OPTS.map(opt=>( ))}
{addPopoverRowId===row.id&&(
{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 ( ); })}
)}
{rowIdx>0&&} {rowIdxmoveRow(row.id,1)} className={ctrlBtn}>↓}
)} {/* Row grid */}
{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 (
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?
{DASHBOARD_WIDGETS.find(d=>d.id===wid)?.label}
:rendered} {editMode&&( )}
); })} {/* Empty slot drop target */} {editMode&&(
{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
)}
); }; // ─── Render ──────────────────────────────────────────────────────── return (
{/* Header */}

{data.settings.name||"Studio"}

{new Date().toLocaleDateString("de-CH",{weekday:"long",day:"numeric",month:"long",year:"numeric"}).toUpperCase()}
{editMode?( <>
{saveTemplateOpen&&(
{canSaveTemplate&&(<>
ÖFFENTLICHE VORLAGE
{(data.dashboardTemplates||[]).filter(t=>t.isPublic).map(t=>( ))}
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"}} />
)}
PERSÖNLICHE VORLAGE
{(data.dashboardTemplates||[]).filter(t=>!t.isPublic&&t.createdBy===currentUser?.id).map(t=>( ))}
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"}} />
)}
):( )}
{/* Edit banner */} {editMode&&(
Dashboard anpassen — Spaltenanzahl und Höhe pro Zeile wählen. Widgets per Drag & Drop verschieben oder mit × entfernen.
)} {/* Rows */} {activeLayout.map((row,idx) => renderRow(row,idx))} {/* Edit mode: add first row / add row at bottom */} {editMode&&(
)}
); } function KpiCard({label,value,sub,color,go,setView}) { return (
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} >
{label}
{value}
{sub&&
{sub}
}
); } function TimeTable({entries,data}) { if (!entries.length) return
Noch keine Zeiteinträge
; return ( {entries.map(e=>{ const proj=data.projects.find(p=>p.id===e.projectId); const phase=SIA_PHASES.find(ph=>ph.id===e.phaseId); return (); })}
DatumProjektBeschreibungDauer
{formatDate(e.date)}
{proj?.name||"—"}
{phase&&
Phase {phase.id}
}
{e.description||"—"} {formatHours(e.minutes)}
); }