00f07d76f6
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>
763 lines
50 KiB
React
Executable File
763 lines
50 KiB
React
Executable File
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 & 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>
|
||
);
|
||
}
|