import React, { useState, useRef, useEffect } from "react"; import { SIA_PHASES, EXPENSE_CATEGORIES } from "../constants.js"; import { generateId, formatDate, formatHours, getAbsenzTypes } from "../utils.js"; import { Header, FormField, Modal, DateInput, DatePicker, CalendarPopup, useCalendarNav, NavArrows } from "../components/UI.jsx"; import { ReceiptViewer } from "./Expenses.jsx"; const SLOT_START_H = 0; const SLOT_END_H = 24; const SLOT_COUNT = (SLOT_END_H - SLOT_START_H) * 4; const SLOT_H = 12; const slotToTime = (idx) => { const m = SLOT_START_H * 60 + idx * 15; return `${String(Math.floor(m/60)).padStart(2,"0")}:${String(m%60).padStart(2,"0")}`; }; const PROJ_COLORS = ["#b07848","#4a7c8c","#6b8c4a","#8c5a7c","#5a6c8c","#8c7a4a","#5a8c7a","#8c6a5a"]; export default function Time({ data, update, currentUser, setPrintContent }) { const myEmployeeId = currentUser?.employeeId || null; const isAdmin = !myEmployeeId; const [currentDate, setCurrentDate] = useState(new Date().toISOString().slice(0, 10)); const [selectedEmpId, setSelectedEmpId] = useState(() => myEmployeeId || ""); const [viewMode, setViewMode] = useState("woche"); const [weekSelectStart, setWeekSelectStart] = useState(null); const [weekHover, setWeekHover] = useState(null); const [weekForm, setWeekForm] = useState(null); const [weekAbsForm, setWeekAbsForm] = useState(null); const [weekEditForm, setWeekEditForm] = useState(null); const [contextMenu, setContextMenu] = useState(null); const weekGridRef = useRef(null); const resizingRef = useRef(null); const [resizeTick, setResizeTick] = useState(0); const dataRef = useRef(data); const updateRef = useRef(update); dataRef.current = data; updateRef.current = update; const employees = data.employees || []; const selectedEmp = employees.find(e => e.id === selectedEmpId) || null; const changeDate = (delta) => { const d = new Date(currentDate); d.setDate(d.getDate() + delta); setCurrentDate(d.toISOString().slice(0, 10)); }; const goToday = () => setCurrentDate(new Date().toISOString().slice(0, 10)); // Wenn MA gewählt: nur dessen Einträge + Einträge ohne MA-Zuweisung ausblenden // Wenn kein MA gewählt: alle Einträge (inkl. ohne MA) const dayEntries = data.timeEntries.filter(e => { if (e.date !== currentDate) return false; if (selectedEmpId) return e.employeeId === selectedEmpId; return true; }).sort((a, b) => (a.createdAt || "").localeCompare(b.createdAt || "")); const dayTotalMins = dayEntries.reduce((s, e) => s + (e.minutes || 0), 0); // Hilfsfunktion: Feiertage für ein Jahr auflösen const resolveFTs = (yr) => { const fts = (data.feiertage || []).map(f => f.repeatsYearly ? { ...f, date: `${yr}-${f.date.slice(5, 10)}` } : f ); // Tag vor Feiertag: -1h (nur wenn stundenDelta nicht schon gesetzt) const ftDates = new Set(fts.filter(f => f.stundenDelta === 0 || f.stundenDelta === null || f.stundenDelta === undefined).map(f => f.date)); const vortage = []; ftDates.forEach(ftDate => { const d = new Date(ftDate); d.setDate(d.getDate() - 1); const ds = d.toISOString().slice(0, 10); const dow = d.getDay(); if (dow !== 0 && dow !== 6 && !ftDates.has(ds) && !fts.some(f => f.date === ds)) { vortage.push({ id: `vortag-${ds}`, date: ds, label: `Vortag ${ftDate}`, stundenDelta: -1 }); } }); return [...fts, ...vortage]; }; // Prüft ob ein Mitarbeiter an einem Tag bestätigte Ferien hat const isApprovedVacationDay = (empId, dateStr) => (data.ferienEntries || []).some(f => { if (f.employeeId !== empId) return false; const active = f.status === "approved" || !f.status || (f.status === "pending" && f.originalData); if (!active) return false; const df = (f.status === "pending" && f.originalData) ? f.originalData.dateFrom : f.dateFrom; const dt = (f.status === "pending" && f.originalData) ? f.originalData.dateTo : f.dateTo; return dateStr >= df && dateStr <= dt; }); // Tages-Soll für gewählten Mitarbeiter const getSollForDay = (emp, dateStr) => { if (!emp) return 0; // Vor Eintrittsdatum: 0 if (emp.eintrittsdatum && dateStr < emp.eintrittsdatum) return 0; const d = new Date(dateStr); const dow = d.getDay(); if (dow === 0 || dow === 6) return 0; const yr = d.getFullYear(); const fts = resolveFTs(yr); const ft = fts.find(f => f.date === dateStr); const pensum = (emp.pensum || 100) / 100; const tagessoll = ((emp.wochenstunden || 35) * pensum) / 5; if (ft && (ft.stundenDelta === 0 || ft.stundenDelta === null || ft.stundenDelta === undefined)) return 0; if (isApprovedVacationDay(emp.id, dateStr)) return 0; if (ft) return Math.max(0, tagessoll + (ft.stundenDelta || 0)); return tagessoll; }; const daySoll = getSollForDay(selectedEmp, currentDate); // Monatssaldo const viewDate2 = new Date(currentDate); const calcMonthSaldo = (emp) => { if (!emp) return null; const yr = viewDate2.getFullYear(); const mo = viewDate2.getMonth(); const monthStr = `${yr}-${String(mo + 1).padStart(2, "0")}`; const today = new Date().toISOString().slice(0, 10); const pensum = (emp.pensum || 100) / 100; const tagessoll = ((emp.wochenstunden || 35) * pensum) / 5; const fts = resolveFTs(yr); let soll = 0, sollTotal = 0; const d = new Date(`${yr}-${String(mo + 1).padStart(2, "0")}-01`); while (d.toISOString().slice(0, 7) === monthStr) { const ds = d.toISOString().slice(0, 10); if (!(emp.eintrittsdatum && ds < emp.eintrittsdatum)) { const dow = d.getDay(); if (dow !== 0 && dow !== 6 && !isApprovedVacationDay(emp.id, ds)) { const ft = fts.find(f => f.date === ds); let h = 0; if (!ft || (ft.stundenDelta === 0 || ft.stundenDelta === null || ft.stundenDelta === undefined)) { h = ft ? 0 : tagessoll; } else { h = Math.max(0, tagessoll + (ft.stundenDelta || 0)); } sollTotal += h; if (ds <= today) soll += h; } } d.setDate(d.getDate() + 1); } const istMins = (data.timeEntries || []).filter(e => e.date?.startsWith(monthStr) && e.employeeId === emp.id).reduce((s, e) => s + (e.minutes || 0), 0); const absenzH = (data.absences || []).filter(a => a.employeeId === emp.id).reduce((s, a) => { const from = a.dateFrom || a.date; const to = a.dateTo || a.date; if (!from) return s; const isSingleDay = from === to; const ad = new Date(from); const ae = new Date(to); let h = 0; while (ad <= ae) { const ds = ad.toISOString().slice(0, 10); if (ds.startsWith(monthStr) && ds <= today && ad.getDay()!==0 && ad.getDay()!==6 && !fts.some(ft=>ft.date===ds&&(ft.stundenDelta===0||ft.stundenDelta===null||ft.stundenDelta===undefined))) { if (isSingleDay && a.startTime && a.endTime) { const [sh,sm]=a.startTime.split(":").map(Number); const [eh,em]=a.endTime.split(":").map(Number); h+=(eh*60+em-sh*60-sm)/60; } else if (isSingleDay) { const ex=(a.hours||0)*60+(a.minutes||0); h+=ex>0?ex/60:tagessoll; } else { h+=tagessoll; } } ad.setDate(ad.getDate()+1); } return s + h; }, 0); const istH = Math.round(istMins / 60 * 10) / 10; return { soll: Math.round(soll*10)/10, sollTotal: Math.round(sollTotal*10)/10, ist: istH, absenz: Math.round(absenzH*10)/10, saldo: Math.round((istH+absenzH-soll)*10)/10, saldoTotal: Math.round((istH+absenzH-sollTotal)*10)/10 }; }; const monthSaldo = calcMonthSaldo(selectedEmp); // Totalsaldo (Eintrittsdatum bis heute) const calcTotalSaldo = (emp) => { if (!emp) return null; const today = new Date().toISOString().slice(0, 10); const start = emp.eintrittsdatum || `${new Date().getFullYear()}-01-01`; if (start > today) return { soll: 0, ist: 0, ferien: 0, absenz: 0, saldo: 0 }; const pensum = (emp.pensum || 100) / 100; const tagessoll = ((emp.wochenstunden || 35) * pensum) / 5; const ftsCache = {}; const getFts = (yr) => { if (!ftsCache[yr]) ftsCache[yr] = resolveFTs(yr); return ftsCache[yr]; }; const todayDate = new Date(today); let soll = 0; const d = new Date(start); while (d <= todayDate) { const ds = d.toISOString().slice(0, 10); const yr = d.getFullYear(); const fts = getFts(yr); const dow = d.getDay(); if (dow !== 0 && dow !== 6 && !isApprovedVacationDay(emp.id, ds)) { const ft = fts.find(f => f.date === ds); if (!ft || ft.stundenDelta === 0 || ft.stundenDelta === null || ft.stundenDelta === undefined) { soll += ft ? 0 : tagessoll; } else { soll += Math.max(0, tagessoll + (ft.stundenDelta || 0)); } } d.setDate(d.getDate() + 1); } const istMins = (data.timeEntries || []).filter(e => e.employeeId === emp.id && e.date >= start && e.date <= today).reduce((s, e) => s + (e.minutes || 0), 0); const absenzH = (data.absences || []).filter(a => a.employeeId === emp.id).reduce((s, a) => { const from = a.dateFrom || a.date; const to = a.dateTo || a.date; if (!from) return s; const isSingleDay = from === to; const ad = new Date(from < start ? start : from); const ae = new Date(to > today ? today : to); let h = 0; while (ad <= ae) { const ds = ad.toISOString().slice(0, 10); const fts = getFts(ad.getFullYear()); if (ad.getDay()!==0 && ad.getDay()!==6 && !fts.some(ft=>ft.date===ds&&(ft.stundenDelta===0||ft.stundenDelta===null||ft.stundenDelta===undefined))) { if (isSingleDay && a.startTime && a.endTime) { const [sh,sm]=a.startTime.split(":").map(Number); const [eh,em]=a.endTime.split(":").map(Number); h+=(eh*60+em-sh*60-sm)/60; } else if (isSingleDay) { const ex=(a.hours||0)*60+(a.minutes||0); h+=ex>0?ex/60:tagessoll; } else { h+=tagessoll; } } ad.setDate(ad.getDate()+1); } return s + h; }, 0); const istH = Math.round(istMins / 60 * 10) / 10; return { soll: Math.round(soll*10)/10, ist: istH, absenz: Math.round(absenzH*10)/10, saldo: Math.round((istH+absenzH-soll)*10)/10 }; }; const totalSaldo = calcTotalSaldo(selectedEmp); // Feriensaldo const calcFerienSaldo = (emp) => { if (!emp) return null; const yr = viewDate2.getFullYear(); const yearStr = String(yr); const pensum = (emp.pensum || 100) / 100; const tagessoll = ((emp.wochenstunden || 35) * pensum) / 5; const yearStart = `${yr}-01-01`; const eintrittsdatum = emp.eintrittsdatum || null; let proRata = 1; if (eintrittsdatum && eintrittsdatum > yearStart) { const entryMonth = parseInt(eintrittsdatum.slice(5, 7)); proRata = (13 - entryMonth) / 12; } const anspruchH = (emp.ferienWochen || 4) * (emp.wochenstunden || 35) * pensum * proRata; const ubertragH = (emp.ferienUebertragVorjahr || {})[yr] || 0; const fts = resolveFTs(yr); const today = new Date().toISOString().slice(0, 10); const isFtFree = (ds) => fts.some(ft => ft.date === ds && (ft.stundenDelta === 0 || ft.stundenDelta === null || ft.stundenDelta === undefined)); const countWorkdays = (from, to, onlyPast, onlyFuture) => { let cnt = 0; const d = new Date(from); const e = new Date(to); while (d <= e) { const ds = d.toISOString().slice(0, 10); if (ds.startsWith(yearStr) && d.getDay() !== 0 && d.getDay() !== 6 && !isFtFree(ds)) { if (onlyPast && ds > today) { d.setDate(d.getDate() + 1); continue; } if (onlyFuture && ds <= today) { d.setDate(d.getDate() + 1); continue; } cnt++; } d.setDate(d.getDate() + 1); } return cnt; }; // Bestätigte Ferien: approved OR pending-with-originalData (use originalData dates) const confirmedEntries = (data.ferienEntries || []).filter(f => f.employeeId === emp.id && (f.status === "approved" || !f.status || (f.status === "pending" && f.originalData)) ).map(f => ({ from: (f.status === "pending" && f.originalData) ? f.originalData.dateFrom : f.dateFrom, to: (f.status === "pending" && f.originalData) ? f.originalData.dateTo : f.dateTo, })); const bezogenH = confirmedEntries.reduce((s, f) => s + countWorkdays(f.from, f.to, true, false) * tagessoll, 0); const geplantH = confirmedEntries.filter(f => f.from > today).reduce((s, f) => s + countWorkdays(f.from, f.to, false, true) * tagessoll, 0); // Beantragt: pending entries (use their requested dates) const beantragtH = (data.ferienEntries || []).filter(f => f.employeeId === emp.id && f.status === "pending" && (f.dateFrom.startsWith(yearStr) || f.dateTo.startsWith(yearStr)) ).reduce((s, f) => s + countWorkdays(f.dateFrom, f.dateTo, false, false) * tagessoll, 0); return { anspruch: Math.round(anspruchH*10)/10, ubertrag: Math.round(ubertragH*10)/10, bezogen: Math.round(bezogenH*10)/10, geplant: Math.round(geplantH*10)/10, beantragt: Math.round(beantragtH*10)/10, rest: Math.round((anspruchH + ubertragH - bezogenH - geplantH) * 10) / 10, }; }; const ferienSaldo = calcFerienSaldo(selectedEmp); const projectHasPhases = (proj) => { if (!proj) return false; return (proj.enabledPhases || []).length > 0 || (proj.customPhases || []).length > 0 || (proj.positions || []).some(pos => (pos.enabledPhases || []).length > 0); }; const getFirstPhaseForProject = (proj) => { if (!proj) return { phaseId: "", positionId: "" }; const cp = proj.customPhases || []; if (cp.length > 0) return { phaseId: cp[0].id, positionId: "" }; const ep = proj.enabledPhases || []; if (ep.length > 0) return { phaseId: ep[0], positionId: "" }; for (const pos of (proj.positions || [])) { if ((pos.enabledPhases || []).length > 0) return { phaseId: pos.enabledPhases[0], positionId: pos.code }; } return { phaseId: "", positionId: "" }; }; const closedMonths = data.settings.closedMonths || []; const isMonthClosed = (date) => closedMonths.includes((date || "").slice(0, 7)); const addEntry = () => { if (isMonthClosed(currentDate)) { alert("Dieser Monat ist abgeschlossen und kann nicht mehr bearbeitet werden."); return; } const firstActive = data.projects.find(p => p.status === "aktiv") || data.projects[0]; const { phaseId, positionId } = getFirstPhaseForProject(firstActive); const toTime = (m) => `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; const todayEnds = data.timeEntries.filter(e => e.date === currentDate && (!selectedEmpId || e.employeeId === selectedEmpId) && e.endTime); let startMins = 8 * 60; if (todayEnds.length > 0) { const maxEnd = todayEnds.reduce((max, e) => { const [h, m] = e.endTime.split(":").map(Number); return Math.max(max, h * 60 + m); }, 0); if (maxEnd > 0) startMins = maxEnd; } const defaultMins = 60; const newEntry = { id: generateId(), date: currentDate, projectId: firstActive?.id || "", phaseId, positionId, minutes: defaultMins, startTime: toTime(startMins), endTime: toTime(startMins + defaultMins), description: "", createdAt: new Date().toISOString(), ...(selectedEmpId ? { employeeId: selectedEmpId } : {}) }; update("timeEntries", [...data.timeEntries, newEntry]); }; const updateEntry = (id, changes) => { const entry = data.timeEntries.find(e => e.id === id); if (entry?.invoiceId) { alert("Dieser Eintrag ist bereits verrechnet und kann nicht mehr geändert werden. Lösche zuerst die zugehörige Rechnung."); return; } if (isMonthClosed(entry?.date)) { alert("Dieser Monat ist abgeschlossen und kann nicht mehr bearbeitet werden."); return; } const merged = { ...entry, ...changes }; if ("minutes" in changes && merged.startTime) { const [h, m] = merged.startTime.split(":").map(Number); const endMins = h * 60 + m + (merged.minutes || 0); changes = { ...changes, endTime: `${String(Math.floor(endMins / 60)).padStart(2, "0")}:${String(endMins % 60).padStart(2, "0")}` }; } update("timeEntries", data.timeEntries.map(e => e.id === id ? { ...e, ...changes } : e)); }; const delEntry = (id) => { const entry = data.timeEntries.find(e => e.id === id); if (entry?.invoiceId) { alert("Dieser Eintrag ist bereits verrechnet und kann nicht gelöscht werden. Lösche zuerst die zugehörige Rechnung."); return; } if (isMonthClosed(entry?.date)) { alert("Dieser Monat ist abgeschlossen und kann nicht mehr gelöscht werden."); return; } update("timeEntries", data.timeEntries.filter(e => e.id !== id)); }; // Tages-Spesen — inline editing const mwstRate = data.settings.mwstRate || 8.1; const dayExpenses = (data.expenses || []).filter(e => { if (e.date !== currentDate) return false; if (selectedEmpId) return e.employeeId === selectedEmpId; return true; }); const addExp = () => { update("expenses", [...(data.expenses || []), { id: generateId(), date: currentDate, category: (data.settings.expenseCategories || EXPENSE_CATEGORIES)[0], projectId: "", description: "", amount: 0, mwstRate, inclMwst: true, employeeId: selectedEmpId || "" }]); }; const updateExp = (id, changes) => update("expenses", (data.expenses || []).map(e => e.id === id ? { ...e, ...changes } : e)); const delExp = (id) => update("expenses", (data.expenses || []).filter(e => e.id !== id)); const [receiptView, setReceiptView] = useState(null); const [uploadingExpId, setUploadingExpId] = useState(null); const expReceiptInputRef = useRef(null); const handleExpReceiptUpload = (ev) => { const file = ev.target.files?.[0]; if (!file || !uploadingExpId) return; const isPdf = file.type === "application/pdf"; const reader = new FileReader(); reader.onload = (e) => { if (isPdf) { updateExp(uploadingExpId, { receiptData: e.target.result, receiptName: file.name }); } else { const img = new Image(); img.onload = () => { const maxW = 1600; const scale = img.width > maxW ? maxW / img.width : 1; const canvas = document.createElement("canvas"); canvas.width = Math.round(img.width * scale); canvas.height = Math.round(img.height * scale); canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height); updateExp(uploadingExpId, { receiptData: canvas.toDataURL("image/jpeg", 0.82), receiptName: file.name }); }; img.src = e.target.result; } }; reader.readAsDataURL(file); ev.target.value = ""; }; // Intern / Absenz — inline editing const absenzTypes = getAbsenzTypes(data); const dayAbs = (data.absences || []).filter(a => { const matchDate = a.date === currentDate || (a.dateFrom && a.dateTo && currentDate >= a.dateFrom && currentDate <= a.dateTo); return matchDate && (!selectedEmpId || a.employeeId === selectedEmpId); }); const dayAbsMins = selectedEmp ? dayAbs.reduce((s, a) => { if (a.startTime && a.endTime) { const [sh,sm]=a.startTime.split(":").map(Number); const [eh,em]=a.endTime.split(":").map(Number); return s+(eh*60+em)-(sh*60+sm); } const ex=(a.hours||0)*60+(a.minutes||0); return s+(ex>0?ex:daySoll*60); }, 0) : 0; const dayIst = (dayTotalMins + dayAbsMins) / 60; const dayDiff = Math.round((dayIst - daySoll) * 10) / 10; const toAbsTime = (m) => `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; const addAbs = () => { const todayAbs = (data.absences || []).filter(a => (a.date === currentDate || (a.dateFrom && a.dateTo && currentDate >= a.dateFrom && currentDate <= a.dateTo)) && (!selectedEmpId || a.employeeId === selectedEmpId) ); let startMins = 8 * 60; if (todayAbs.length > 0) { const maxEnd = todayAbs.reduce((max, a) => { let endM; if (a.endTime) { const [h,m]=a.endTime.split(":").map(Number); endM=h*60+m; } else { const dur=(a.hours||0)*60+(a.minutes||0); endM=8*60+(dur>0?dur:Math.round(daySoll*60)); } return Math.max(max, endM); }, 8 * 60); if (maxEnd > 8 * 60) startMins = maxEnd; } const endMins = startMins + 60; update("absences", [...(data.absences || []), { id: generateId(), date: currentDate, employeeId: selectedEmpId || (employees[0]?.id || ""), type: absenzTypes[0]?.id || "", startTime: toAbsTime(startMins), endTime: toAbsTime(endMins), hours: 1, minutes: 0, note: "", status: "pending", createdAt: new Date().toISOString() }]); }; const updateAbs = (id, changes) => { const abs = (data.absences || []).find(a => a.id === id); if (!abs) return; if (abs.startTime && ("hours" in changes || "minutes" in changes)) { const [sh, sm] = abs.startTime.split(":").map(Number); const newH = "hours" in changes ? changes.hours : (abs.hours || 0); const newM = "minutes" in changes ? changes.minutes : (abs.minutes || 0); const endMins = Math.min(sh * 60 + sm + newH * 60 + newM, 23 * 60 + 59); changes = { ...changes, endTime: toAbsTime(endMins) }; } update("absences", (data.absences || []).map(a => a.id === id ? { ...a, ...changes } : a)); }; const delAbs = (id) => update("absences", (data.absences || []).filter(a => a.id !== id)); // Ferien-Antrag Modal const [antragModal, setAntragModal] = useState(null); const [antragForm, setAntragForm] = useState({}); const openFerienAntrag = () => { setAntragForm({ employeeId: selectedEmpId || (employees[0]?.id || ""), dateFrom: currentDate, dateTo: currentDate, note: "" }); setAntragModal("ferien"); }; const [antragSaved, setAntragSaved] = useState(false); const saveAntrag = () => { if (!antragForm.employeeId || !antragForm.dateFrom || !antragForm.dateTo) return; if (antragForm.id) { const existing = (data.ferienEntries || []).find(f => f.id === antragForm.id); let entry; if (existing?.status === "approved" || !existing?.status) { entry = { ...antragForm, status: "pending", originalData: existing?.originalData || { dateFrom: existing.dateFrom, dateTo: existing.dateTo, note: existing.note || "" }, }; } else { entry = { ...antragForm, status: "pending" }; } update("ferienEntries", (data.ferienEntries || []).map(f => f.id === entry.id ? entry : f)); } else { const entry = { ...antragForm, id: generateId(), status: "pending", createdAt: new Date().toISOString() }; update("ferienEntries", [...(data.ferienEntries || []), entry]); } setAntragModal(null); setAntragSaved(true); setTimeout(() => setAntragSaved(false), 3000); }; const deleteFerien = (id) => update("ferienEntries", (data.ferienEntries || []).filter(f => f.id !== id)); const markFerienSeen = (id) => update("ferienEntries", (data.ferienEntries || []).map(f => f.id === id ? { ...f, seen: true } : f)); const approveFerien = (id) => update("ferienEntries", (data.ferienEntries || []).map(f => { if (f.id !== id) return f; const { originalData, ...rest } = f; return { ...rest, status: "approved" }; })); const cancelFerienEdit = (id) => update("ferienEntries", (data.ferienEntries || []).map(f => { if (f.id !== id || !f.originalData) return f; const { originalData, ...rest } = f; return { ...rest, dateFrom: originalData.dateFrom, dateTo: originalData.dateTo, note: originalData.note, status: "approved" }; })); const editFerien = (f) => { setAntragForm({ ...f }); setAntragModal("ferien"); }; const [ferienUebersicht, setFerienUebersicht] = useState(false); const navCal = useCalendarNav(); // Pendente Ferienanträge const myPendingFerien = (data.ferienEntries || []).filter(f => (!selectedEmpId || f.employeeId === selectedEmpId) && f.status === "pending"); const totalPending = myPendingFerien.length; const viewDate = new Date(currentDate); const year = viewDate.getFullYear(); const month = viewDate.getMonth(); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const startWeekday = (firstDay.getDay() + 6) % 7; // Montag = 0 const calFTs = resolveFTs(year); const todayStr = new Date().toISOString().slice(0, 10); const monthCells = []; for (let i = 0; i < startWeekday; i++) monthCells.push(null); for (let d = 1; d <= lastDay.getDate(); d++) { const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; const mins = data.timeEntries.filter(e => e.date === dateStr && (!selectedEmpId || e.employeeId === selectedEmpId || !e.employeeId)).reduce((s, e) => s + (e.minutes || 0), 0); const ft = calFTs.find(f => f.date === dateStr); const dow = new Date(dateStr).getDay(); const isWeekend = dow === 0 || dow === 6; const isFeiertag = ft && (ft.stundenDelta === 0 || ft.stundenDelta === null || ft.stundenDelta === undefined); const isVortag = ft && ft.stundenDelta === -1 && (ft.id || "").startsWith("vortag-"); const sollH = selectedEmp ? getSollForDay(selectedEmp, dateStr) : null; // Ferien/Absenz für diesen Tag (nur wenn Mitarbeiter gewählt) const isFerien = selectedEmpId ? isApprovedVacationDay(selectedEmpId, dateStr) : false; const isPendingFerien = selectedEmpId ? (data.ferienEntries || []).some(f => f.employeeId === selectedEmpId && f.status === "pending" && dateStr >= f.dateFrom && dateStr <= f.dateTo) : false; const isAbsenz = selectedEmpId ? (data.absences || []).some(a => a.employeeId === selectedEmpId && (!a.status || a.status === "approved") && (a.date === dateStr || (a.dateFrom && a.dateTo && dateStr >= a.dateFrom && dateStr <= a.dateTo))) : false; const isPendingAbsenz = selectedEmpId ? (data.absences || []).some(a => a.employeeId === selectedEmpId && a.status === "pending" && (a.date === dateStr || (a.dateFrom && a.dateTo && dateStr >= a.dateFrom && dateStr <= a.dateTo))) : false; monthCells.push({ day: d, dateStr, mins, ft, isWeekend, isFeiertag, isVortag, sollH, isFerien, isPendingFerien, isAbsenz, isPendingAbsenz, isPast: dateStr <= todayStr }); } const monthTotalMins = monthCells.filter(c => c).reduce((s, c) => s + c.mins, 0); const weekdayLabel = new Date(currentDate).toLocaleDateString("de-CH", { weekday: "long" }); const dateLabel = new Date(currentDate).toLocaleDateString("de-CH", { day: "numeric", month: "long", year: "numeric" }); const monthLabel = viewDate.toLocaleDateString("de-CH", { month: "long", year: "numeric" }); const isToday = currentDate === new Date().toISOString().slice(0, 10); const getWeekDays = (ds) => { const d = new Date(ds); const dow = d.getDay(); const mon = new Date(d); mon.setDate(d.getDate() - (dow === 0 ? 6 : dow - 1)); return Array.from({ length: 7 }, (_, i) => { const day = new Date(mon); day.setDate(mon.getDate() + i); return day.toISOString().slice(0, 10); }); }; const weekDays = getWeekDays(currentDate); const weekLabel = (() => { const from = new Date(weekDays[0]); const to = new Date(weekDays[6]); return `${from.toLocaleDateString("de-CH", { day: "numeric", month: "short" })} – ${to.toLocaleDateString("de-CH", { day: "numeric", month: "short", year: "numeric" })}`; })(); const changeWeek = (delta) => { const d = new Date(currentDate); d.setDate(d.getDate() + delta * 7); setCurrentDate(d.toISOString().slice(0, 10)); setWeekForm(null); setWeekSelectStart(null); setWeekAbsForm(null); setWeekEditForm(null); }; const handleSlotClick = (dayStr, slotIdx) => { if (isMonthClosed(dayStr)) return; if (selectedEmp?.eintrittsdatum && dayStr < selectedEmp.eintrittsdatum) return; if (!weekSelectStart) { setWeekSelectStart({ dayStr, slotIdx }); return; } if (weekSelectStart.dayStr !== dayStr) { setWeekSelectStart({ dayStr, slotIdx }); return; } const startSlot = Math.min(weekSelectStart.slotIdx, slotIdx); const endSlot = Math.max(weekSelectStart.slotIdx, slotIdx); const firstActive = data.projects.find(p => p.status === "aktiv") || data.projects[0]; setWeekForm({ dayStr, startSlot, endSlot, mode: "projekt", projectId: firstActive?.id || "", phaseId: "", positionId: "", description: "", absType: absenzTypes[0]?.id || "", absNote: "" }); setWeekEditForm(null); setWeekSelectStart(null); }; const saveWeekEntry = () => { if (isMonthClosed(weekForm?.dayStr)) return; const mins = (weekForm.endSlot - weekForm.startSlot + 1) * 15; if (weekForm?.mode === "absenz") { update("absences", [...(data.absences || []), { id: generateId(), date: weekForm.dayStr, employeeId: selectedEmpId || "", type: weekForm.absType || (absenzTypes[0]?.id || ""), startTime: slotToTime(weekForm.startSlot), endTime: slotToTime(weekForm.endSlot + 1), minutes: mins, hours: 0, note: weekForm.absNote || "", status: "pending", createdAt: new Date().toISOString(), }]); setWeekForm(null); return; } if (!weekForm?.projectId) return; update("timeEntries", [...data.timeEntries, { id: generateId(), date: weekForm.dayStr, projectId: weekForm.projectId, phaseId: weekForm.phaseId || "", positionId: weekForm.positionId || "", minutes: mins, startTime: slotToTime(weekForm.startSlot), endTime: slotToTime(weekForm.endSlot + 1), description: weekForm.description || "", employeeId: selectedEmpId || "", createdAt: new Date().toISOString(), }]); setWeekForm(null); }; const saveWeekAbs = () => { if (!weekAbsForm) return; const h = weekAbsForm.hours || 0; const m = weekAbsForm.minutes || 0; const startMins = 8 * 60; const durMins = (h > 0 || m > 0) ? h * 60 + m : Math.round(getSollForDay(selectedEmp, weekAbsForm.dayStr) * 60) || 60; update("absences", [...(data.absences || []), { id: generateId(), date: weekAbsForm.dayStr, employeeId: selectedEmpId || (employees[0]?.id || ""), type: weekAbsForm.type || (absenzTypes[0]?.id || ""), startTime: toAbsTime(startMins), endTime: toAbsTime(startMins + durMins), hours: h, minutes: m, note: weekAbsForm.note || "", status: "pending", createdAt: new Date().toISOString(), }]); setWeekAbsForm(null); }; useEffect(() => { if (viewMode === "woche" && weekGridRef.current) { weekGridRef.current.scrollTop = (7 - SLOT_START_H) * 4 * SLOT_H; } }, [viewMode]); useEffect(() => { const onMove = (ev) => { const r = resizingRef.current; if (!r) return; const scrollTop = weekGridRef.current?.scrollTop || 0; const absY = ev.clientY + scrollTop; const deltaSlots = Math.round((absY - r.startAbsY) / SLOT_H); if (deltaSlots !== 0) r.moved = true; const origDur = Math.round(r.origMinutes / 15); let newStart, newDur; if (r.edge === "bottom") { newStart = r.origSlot; newDur = Math.max(1, origDur + deltaSlots); } else if (r.edge === "top") { const s = Math.max(0, Math.min(r.origSlot + deltaSlots, r.origSlot + origDur - 1)); newStart = s; newDur = r.origSlot + origDur - s; } else { newStart = Math.max(0, Math.min(SLOT_COUNT - origDur, r.origSlot + deltaSlots)); newDur = origDur; } if (newStart + newDur > SLOT_COUNT) newDur = SLOT_COUNT - newStart; const overlaps = !r.isAbs && r.edge !== "move" && dataRef.current.timeEntries.filter(e2 => e2.date === r.dayStr && e2.startTime && e2.id !== r.entryId && (!r.empId || e2.employeeId === r.empId) ).map(e2 => { const [h,m] = e2.startTime.split(":").map(Number); const s = Math.round((h*60+m-SLOT_START_H*60)/15); return { start: s, end: s + Math.round(e2.minutes/15) }; }) .some(o => newStart < o.end && newStart + newDur > o.start); if (!overlaps) { r.previewStart = slotToTime(newStart); r.previewMins = newDur * 15; } setResizeTick(t => t + 1); }; const onUp = () => { const r = resizingRef.current; if (r) { if (!r.moved) { setWeekEditForm({ entryId: r.entryId, isAbs: r.isAbs || false }); } else if (r.previewMins !== undefined) { const [sh, sm] = r.previewStart.split(":").map(Number); const endTime = slotToTime((sh * 60 + sm - SLOT_START_H * 60) / 15 + r.previewMins / 15); const pStart = Math.round((sh * 60 + sm - SLOT_START_H * 60) / 15); const pEnd = pStart + Math.round(r.previewMins / 15); if (r.isAbs) { updateRef.current("absences", dataRef.current.absences.map(a => a.id === r.entryId ? { ...a, startTime: r.previewStart, endTime, minutes: r.previewMins } : a )); } else { let newEntries = dataRef.current.timeEntries.map(e => e.id === r.entryId ? { ...e, startTime: r.previewStart, endTime, minutes: r.previewMins } : e ); if (r.edge === "move") { newEntries = newEntries.map(e2 => { if (e2.id === r.entryId || e2.date !== r.dayStr || !e2.startTime) return e2; if (r.empId && e2.employeeId !== r.empId) return e2; const [h, m] = e2.startTime.split(":").map(Number); const s = Math.round((h * 60 + m - SLOT_START_H * 60) / 15); const dur = Math.round(e2.minutes / 15); if (s >= pEnd || s + dur <= pStart) return e2; const newS = s >= pStart ? pEnd : pStart - dur; if (newS < 0 || newS + dur > SLOT_COUNT) return e2; return { ...e2, startTime: slotToTime(newS), endTime: slotToTime(newS + dur) }; }); } updateRef.current("timeEntries", newEntries); } } } resizingRef.current = null; setResizeTick(t => t + 1); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); return () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; }, []); const duplicateToNextDay = (entry) => { const d = new Date(entry.date); d.setDate(d.getDate() + 1); const nextDay = d.toISOString().slice(0, 10); update("timeEntries", [...data.timeEntries, { ...entry, id: generateId(), date: nextDay, createdAt: new Date().toISOString() }]); }; return (
) : null} />
{/* Tages-Erfassung */}
{!selectedEmpId && isAdmin && (
{employees.length === 0 ? ( <>
Noch keine Mitarbeiter erfasst
Mitarbeiter unter «Mitarbeiter» anlegen
) : ( <>
Bitte zuerst einen Mitarbeiter wählen
Mitarbeiterwahl rechts →
)}
)}
viewMode === "tag" ? changeDate(-1) : changeWeek(-1)} onNext={() => viewMode === "tag" ? changeDate(1) : changeWeek(1)} />
{viewMode === "tag" ? (
navCal.setOpen(o => !o)} style={{ cursor: "pointer", display: "inline-block" }} title="Datum wählen">
{`${weekdayLabel}, ${new Date(currentDate).toLocaleDateString("de-CH", { day: "numeric", month: "long" })}`}
) : (
{weekLabel}
)} {navCal.open && ( { setCurrentDate(ds); navCal.setOpen(false); }} onClose={() => navCal.setOpen(false)} showClear={false} /> )}
{!isToday && }
{[["tag", "Tag"], ["woche", "Woche"]].map(([mode, label], i) => ( {i > 0 &&
} ))}
{viewMode === "tag" ? ( <>
{dayEntries.length === 0 && ( )} {dayEntries.map(e => { const proj = data.projects.find(p => p.id === e.projectId); const projEnabledIds = proj?.enabledPhases || []; const enabledPhases = projEnabledIds.map(id => SIA_PHASES.find(ph => ph.id === id)).filter(Boolean); const positions = proj?.positions || []; const customPhases = proj?.customPhases || []; const isInvoiced = !!e.invoiceId; const disabled = isInvoiced || isMonthClosed(e.date); const hasAnyPhase = enabledPhases.length > 0 || customPhases.length > 0 || positions.some(pos => (pos.enabledPhases || []).length > 0); const combinedVal = e.phaseId ? (e.positionId ? e.phaseId + "|" + e.positionId : e.phaseId) : ""; const onCombinedChange = (val) => { if (!val) { updateEntry(e.id, { phaseId: "", positionId: "" }); return; } const [ph, pos] = val.split("|"); updateEntry(e.id, { phaseId: ph, positionId: pos || "" }); }; return ( ); })} {selectedEmp && (daySoll > 0 || dayTotalMins > 0) && ( )}
Projekt Phase / Position Tätigkeit Std Min
Keine Einträge an diesem Tag
updateEntry(e.id, { description: ev.target.value })} placeholder="Was wurde gemacht?" style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} /> updateEntry(e.id, { minutes: (+ev.target.value * 60) + ((e.minutes || 0) % 60) })} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} />
Tagestotal: {formatHours(dayTotalMins)}
{selectedEmp.name}{daySoll === 0 ? " · Wochenende / Feiertag" : " · Soll heute"} {daySoll > 0 ? (<>{Math.round(daySoll * 10) / 10}h Soll ·  0 ? "#2d6a4f" : "#8a1a1a" }}>{dayDiff === 0 ? "±0h" : `${dayDiff > 0 ? "+" : ""}${dayDiff}h`}) : ({Math.round(dayIst * 10) / 10}h)}
{dayAbs.length > 0 && (
ABSENZEN
{dayAbs.map(a => { const absTotalMins = a.startTime && a.endTime ? (() => { const [sh,sm]=a.startTime.split(":").map(Number); const [eh,em]=a.endTime.split(":").map(Number); return Math.max(0,(eh*60+em)-(sh*60+sm)); })() : (a.hours||0)*60+(a.minutes||0); const absH = Math.floor(absTotalMins / 60); const absM = absTotalMins % 60; return ( ); })}
TypBemerkungStdMin
updateAbs(a.id, { note: ev.target.value })} placeholder="Bemerkung…" style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} /> updateAbs(a.id, { hours: +ev.target.value, minutes: absM })} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} />
)} {dayAbs.length === 0 && (
)} {dayExpenses.length > 0 && (
SPESEN
{dayExpenses.map(e => ( ))}
KategorieProjektBeschreibungBetrag CHFBeleg
updateExp(e.id, { description: ev.target.value })} placeholder="Beschreibung…" style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} /> updateExp(e.id, { amount: +ev.target.value })} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} /> {e.receiptData ? ( ) : ( )}
Total: {(dayExpenses.reduce((s, e) => s + (e.amount || 0), 0)).toFixed(2)}
)} {dayExpenses.length === 0 && (
)} ) : ( /* WOCHENANSICHT */
{void resizeTick /* trigger re-render during resize */} {resizingRef.current && } {/* Zeitraster mit sticky Tageskopf */}
setWeekHover(null)}> {/* Sticky Tageskopf */}
{weekDays.map(ds => { const d = new Date(ds); const isThisToday = ds === todayStr; const isCurDay = ds === currentDate; const ft = calFTs.find(f => f.date === ds); const isFeiertag = ft && (ft.stundenDelta === 0 || ft.stundenDelta === null || ft.stundenDelta === undefined); const dow = d.getDay(); const noWork = dow === 0 || dow === 6 || isFeiertag; const isBeforeEintritt = !!(selectedEmp?.eintrittsdatum && ds < selectedEmp.eintrittsdatum); const wkTotal = data.timeEntries.filter(e => e.date === ds && (!selectedEmpId || e.employeeId === selectedEmpId)).reduce((s, e) => s + (e.minutes || 0), 0); const absMinutes = selectedEmpId ? (data.absences || []).filter(a => { const matchDate = a.date === ds || (a.dateFrom && a.dateTo && ds >= a.dateFrom && ds <= a.dateTo); return matchDate && a.employeeId === selectedEmpId; }).reduce((s, a) => { if (a.startTime && a.endTime) { const [sh,sm]=a.startTime.split(":").map(Number); const [eh,em]=a.endTime.split(":").map(Number); return s+(eh*60+em)-(sh*60+sm); } const ex=(a.hours||0)*60+(a.minutes||0); return s+(ex>0?ex:getSollForDay(selectedEmp,ds)*60); }, 0) : 0; const istH = (wkTotal + absMinutes) / 60; const sollH = selectedEmp ? getSollForDay(selectedEmp, ds) : 0; const isPast = ds <= todayStr; const isVacDay = selectedEmpId ? isApprovedVacationDay(selectedEmpId, ds) : false; const isPendingVacDay = !isVacDay && selectedEmpId ? (data.ferienEntries || []).some(f => f.employeeId === selectedEmpId && f.status === "pending" && !f.originalData && ds >= f.dateFrom && ds <= f.dateTo) : false; const diff = Math.round((istH - sollH) * 10) / 10; return (
21) ? "#e8f0fa" : "#e8f5ee") : isPendingVacDay ? "#fff8e8" : isFeiertag ? "#f5f2ed" : "#fff" }} onClick={() => { setCurrentDate(ds); setViewMode("tag"); }}>
{d.toLocaleDateString("de-CH", { weekday: "short" }).toUpperCase()}
{d.getDate()}
{(() => { if (!selectedEmp || noWork || isBeforeEintritt) return wkTotal > 0 ?
{istH.toFixed(1)}h
: null; if (isVacDay && wkTotal === 0) { const daysAway = Math.ceil((new Date(ds) - new Date(todayStr)) / 86400000); const isPlanned = !isPast && daysAway > 21; const color = isCurDay ? "#a0c4e8" : isPlanned ? "#1a4e8a" : "#2d6a4f"; return
Ferien
; } if (isPendingVacDay && wkTotal === 0) return
Ferien?
; if (!isPast) return wkTotal > 0 ?
{istH.toFixed(1)}h
: null; const saldoColor = isCurDay ? "#e8e5df" : diff === 0 ? "#555" : diff > 0 ? "#2d6a4f" : "#b5621e"; const saldoText = (wkTotal + absMinutes) === 0 ? `-${sollH.toFixed(1)}h` : diff === 0 ? `${istH.toFixed(1)}h` : `${diff > 0 ? "+" : ""}${diff.toFixed(1)}h`; return
{saldoText}
; })()}
); })}
{/* Zeitgitter */}
{Array.from({ length: SLOT_COUNT }, (_, i) => { const hour = i / 4; const isOffHour = hour < 7 || hour >= 19; const isHourMark = i % 4 === 0 && i > 0; const labelColor = isOffHour ? "#7a9db8" : "#888"; return (
{isHourMark ? slotToTime(i) : ""}
); })}
{weekDays.map(ds => { const colTimed = data.timeEntries.filter(e => e.date === ds && e.startTime && (!selectedEmpId || e.employeeId === selectedEmpId)); const ft = calFTs.find(f => f.date === ds); const isFeiertag = ft && (ft.stundenDelta === 0 || ft.stundenDelta === null || ft.stundenDelta === undefined); const colBeforeEintritt = !!(selectedEmp?.eintrittsdatum && ds < selectedEmp.eintrittsdatum); const isMaiTag = ds.slice(5) === "05-01"; const maiTagBlocked = isMaiTag && (data.settings.blockMaiTag !== false); return (
{Array.from({ length: SLOT_COUNT }, (_, slotIdx) => { const isHour = slotIdx % 4 === 0; const isHalf = slotIdx % 2 === 0 && !isHour; const slotHour = slotIdx / 4; const isOffHours = slotHour < 7 || slotHour >= 19; const inHover = weekSelectStart?.dayStr === ds && weekHover?.dayStr === ds && slotIdx >= Math.min(weekSelectStart.slotIdx, weekHover.slotIdx) && slotIdx <= Math.max(weekSelectStart.slotIdx, weekHover.slotIdx); const isStart = weekSelectStart?.dayStr === ds && slotIdx === weekSelectStart.slotIdx && !weekHover; const inSel = weekForm?.dayStr === ds && slotIdx >= weekForm.startSlot && slotIdx <= weekForm.endSlot; const blocked = maiTagBlocked || colBeforeEintritt; const offBg = isOffHours ? "rgba(100,140,175,0.06)" : "transparent"; return (
0 ? `1px solid ${isHour ? (isOffHours ? "#e4eaf0" : "#e8e3dc") : isHalf ? "#f0ede8" : "#f7f5f2"}` : "none", background: inSel ? "rgba(212,168,90,0.15)" : (inHover || isStart) ? "rgba(212,168,90,0.08)" : offBg, cursor: blocked ? "default" : "crosshair" }} onClick={() => !blocked && !resizingRef.current && handleSlotClick(ds, slotIdx)} onMouseEnter={() => setWeekHover({ dayStr: ds, slotIdx })} /> ); })} {colTimed.map(e => { const r = resizingRef.current; const isRes = r?.entryId === e.id; const dispStart = isRes && r.previewStart !== undefined ? r.previewStart : e.startTime; const dispMins = isRes && r.previewMins !== undefined ? r.previewMins : e.minutes; const [sh, sm] = dispStart.split(":").map(Number); const top = ((sh * 60 + sm) - SLOT_START_H * 60) / 15 * SLOT_H; if (top < 0) return null; const height = Math.max(SLOT_H, (dispMins / 15) * SLOT_H) - 2; const proj = data.projects.find(p => p.id === e.projectId); const bg = PROJ_COLORS[data.projects.findIndex(p => p.id === e.projectId) % PROJ_COLORS.length] || PROJ_COLORS[0]; const handleH = Math.min(5, Math.floor(height / 3)); const phaseDisplay = e.phaseId ? (() => { const cp = (proj?.customPhases||[]).find(c => c.id === e.phaseId); if (cp) return cp.label; const sia = SIA_PHASES.find(p => p.id === e.phaseId); return sia ? (e.positionId ? `${e.positionId}·${e.phaseId}` : e.phaseId) : e.phaseId; })() : null; const startResizing = (ev, edge) => { if (isMonthClosed(ds)) return; ev.stopPropagation(); ev.preventDefault(); const [s, m] = e.startTime.split(":").map(Number); const origSlot = Math.round((s * 60 + m - SLOT_START_H * 60) / 15); resizingRef.current = { entryId: e.id, dayStr: ds, empId: selectedEmpId, edge, moved: false, startAbsY: ev.clientY + (weekGridRef.current?.scrollTop || 0), origMinutes: e.minutes, origStartTime: e.startTime, origSlot, previewStart: e.startTime, previewMins: e.minutes }; setResizeTick(t => t + 1); }; const isMoving = isRes && resizingRef.current?.edge === "move"; return (
{ ev.preventDefault(); setContextMenu({ x: ev.clientX, y: ev.clientY, entry: e }); }} style={{ position: "absolute", top, left: 2, right: 2, height, background: bg, borderRadius: 3, padding: `${handleH}px 4px`, fontSize: 9, color: "#fff", overflow: "hidden", zIndex: isRes ? 10 : 2, boxSizing: "border-box", userSelect: "none" }}>
startResizing(ev, "top")} />
startResizing(ev, "move")} />
{proj?.name||"—"}
{phaseDisplay && height > 24 &&
{phaseDisplay}
} {height > 36 &&
{slotToTime((sh*60+sm-SLOT_START_H*60)/15)} – {slotToTime((sh*60+sm-SLOT_START_H*60)/15 + dispMins/15)}
} {height > 52 && e.description &&
{e.description}
}
startResizing(ev, "bottom")} />
); })} {/* Absenz-Blöcke im Zeitraster */} {(data.absences || []).filter(a => a.date === ds && a.startTime && a.endTime && (!selectedEmpId || a.employeeId === selectedEmpId)).map(a => { const rA = resizingRef.current; const isResA = rA?.entryId === a.id; const dispStartA = isResA && rA.previewStart !== undefined ? rA.previewStart : a.startTime; const [shA, smA] = dispStartA.split(":").map(Number); const dispMinsA = isResA && rA.previewMins !== undefined ? rA.previewMins : (() => { const [eh,em]=a.endTime.split(":").map(Number); return (eh*60+em)-(shA*60+smA); })(); const topA = ((shA * 60 + smA) - SLOT_START_H * 60) / 15 * SLOT_H; if (topA < 0) return null; const heightA = Math.max(SLOT_H, (dispMinsA / 15) * SLOT_H) - 2; const handleHA = Math.min(5, Math.floor(heightA / 3)); const t = absenzTypes.find(x => x.id === a.type); const bg = t?.color || "#888"; const isMovingA = isResA && rA?.edge === "move"; const origDurA = (() => { const [eh,em]=a.endTime.split(":").map(Number); return (eh*60+em)-(shA*60+smA); })(); const startResizingA = (ev, edge) => { if (isMonthClosed(ds)) return; ev.stopPropagation(); ev.preventDefault(); const [s, m] = a.startTime.split(":").map(Number); const origSlot = Math.round((s * 60 + m - SLOT_START_H * 60) / 15); resizingRef.current = { entryId: a.id, dayStr: ds, empId: selectedEmpId, isAbs: true, edge, moved: false, startAbsY: ev.clientY + (weekGridRef.current?.scrollTop || 0), origMinutes: origDurA, origStartTime: a.startTime, origSlot, previewStart: a.startTime, previewMins: origDurA }; setResizeTick(t => t + 1); }; return (
startResizingA(ev, "top")} />
startResizingA(ev, "move")} />
{t?.label || a.type}
{heightA > 28 &&
{dispStartA} – {slotToTime((shA*60+smA-SLOT_START_H*60)/15 + dispMinsA/15)}
} {heightA > 44 && a.note &&
{a.note}
}
startResizingA(ev, "bottom")} />
); })} {ds === todayStr && (() => { const now = new Date(); const top = (now.getHours() * 60 + now.getMinutes() - SLOT_START_H * 60) / 15 * SLOT_H; if (top < 0 || top > SLOT_COUNT * SLOT_H) return null; return
; })()}
); })}
{/* end display:flex time grid */}
{/* end scroll container */} {weekSelectStart && !weekForm && (
{slotToTime(weekSelectStart.slotIdx)} ausgewählt — Endzeit anklicken
)} {weekForm && (() => { const isAbsMode = weekForm.mode === "absenz"; const accentColor = isAbsMode ? (absenzTypes.find(t => t.id === weekForm.absType)?.color || "#6b5a8a") : "var(--accent)"; const proj = data.projects.find(p => p.id === weekForm.projectId); const enabledPhases = (proj?.enabledPhases||[]).map(id => SIA_PHASES.find(ph => ph.id === id)).filter(Boolean); const customPhases = proj?.customPhases || []; const positions = proj?.positions || []; const hasPhase = enabledPhases.length > 0 || customPhases.length > 0 || positions.some(pos => (pos.enabledPhases||[]).length > 0); const combinedVal = weekForm.phaseId ? (weekForm.positionId ? weekForm.phaseId+"|"+weekForm.positionId : weekForm.phaseId) : ""; const onPhaseChange = (val) => { if (!val) { setWeekForm({...weekForm, phaseId:"", positionId:""}); return; } const [ph,pos]=val.split("|"); setWeekForm({...weekForm, phaseId:ph, positionId:pos||""}); }; const mins = (weekForm.endSlot - weekForm.startSlot + 1) * 15; return (
{new Date(weekForm.dayStr).toLocaleDateString("de-CH", { weekday: "long", day: "numeric", month: "long" }).toUpperCase()} · {slotToTime(weekForm.startSlot)} – {slotToTime(weekForm.endSlot + 1)} · {mins >= 60 ? `${mins%60===0?mins/60:(mins/60).toFixed(1)}h` : `${mins} min`}
{[["projekt", "Projekt"], ["absenz", "Intern / Absenz"]].map(([m, label]) => ( ))}
{isAbsMode ? ( <>
TYP
NOTIZ
setWeekForm({ ...weekForm, absNote: e.target.value })} placeholder="Notiz…" style={{ height: 34, fontSize: 12, width: "100%", boxSizing: "border-box" }} onKeyDown={e => e.key === "Enter" && saveWeekEntry()} />
) : ( <>
PROJEKT
{hasPhase && (
PHASE *
)}
TÄTIGKEIT
setWeekForm({...weekForm, description:e.target.value})} placeholder="Beschreibung…" style={{ height: 34, fontSize: 12, width: "100%", boxSizing: "border-box" }} onKeyDown={e => e.key === "Enter" && saveWeekEntry()} />
)}
); })()} {weekEditForm && !weekForm && (() => { if (weekEditForm.isAbs) { const a = (data.absences||[]).find(x => x.id === weekEditForm.entryId); if (!a) return null; const t = absenzTypes.find(x => x.id === a.type); const accentColor = t?.color || "#6b5a8a"; const [shE,smE]=a.startTime.split(":").map(Number); const [ehE,emE]=a.endTime.split(":").map(Number); const durE = (ehE*60+emE)-(shE*60+smE); return (
{new Date(a.date).toLocaleDateString("de-CH", { weekday: "long", day: "numeric", month: "long" }).toUpperCase()} · {a.startTime} – {a.endTime} · {(durE/60).toFixed(1)}h
TYP
NOTIZ
update("absences", (data.absences||[]).map(x => x.id === a.id ? {...x, note: ev.target.value} : x))} placeholder="Notiz…" style={{ height: 34, fontSize: 12, width: "100%", boxSizing: "border-box" }} />
); } else { const e = data.timeEntries.find(x => x.id === weekEditForm.entryId); if (!e) return null; const projE = data.projects.find(p => p.id === e.projectId); const enabledPhasesE = (projE?.enabledPhases||[]).map(id => SIA_PHASES.find(ph => ph.id === id)).filter(Boolean); const customPhasesE = projE?.customPhases || []; const positionsE = projE?.positions || []; const hasPhaseE = enabledPhasesE.length > 0 || customPhasesE.length > 0 || positionsE.some(pos => (pos.enabledPhases||[]).length > 0); const combinedValE = e.phaseId ? (e.positionId ? e.phaseId+"|"+e.positionId : e.phaseId) : ""; const onPhaseChangeE = (val) => { if (!val) { updateEntry(e.id, { phaseId: "", positionId: "" }); return; } const [ph, pos] = val.split("|"); updateEntry(e.id, { phaseId: ph, positionId: pos || "" }); }; const isInvoiced = !!e.invoiceId; const disabledE = isInvoiced || isMonthClosed(e.date); const bgE = PROJ_COLORS[data.projects.findIndex(p => p.id === e.projectId) % PROJ_COLORS.length] || PROJ_COLORS[0]; const durE = e.startTime && e.endTime ? (() => { const [sh,sm]=e.startTime.split(":").map(Number); const [eh,em]=e.endTime.split(":").map(Number); return (eh*60+em)-(sh*60+sm); })() : e.minutes; return (
{new Date(e.date).toLocaleDateString("de-CH", { weekday: "long", day: "numeric", month: "long" }).toUpperCase()} · {e.startTime} – {e.endTime} · {(durE/60).toFixed(1)}h {isInvoiced && verrechnet}
PROJEKT
{hasPhaseE && (
PHASE
)}
TÄTIGKEIT
updateEntry(e.id, { description: ev.target.value })} placeholder="Beschreibung…" style={{ height: 34, fontSize: 12, width: "100%", boxSizing: "border-box" }} />
); } })()}
)}
{/* Modal: Ferien-Übersicht */} {ferienUebersicht && selectedEmp && ( setFerienUebersicht(false)}> {(() => { const today = new Date().toISOString().slice(0, 10); const empFerien = (data.ferienEntries || []).filter(f => f.employeeId === selectedEmp.id).sort((a, b) => (b.dateFrom || "").localeCompare(a.dateFrom || "")); if (empFerien.length === 0) return
Keine Ferieneinträge vorhanden.
; const groups = [ { label: "Pendent", entries: empFerien.filter(f => f.status === "pending") }, { label: "Bestätigt", entries: empFerien.filter(f => f.status !== "pending") }, ]; return groups.filter(g => g.entries.length > 0).map(g => (
{g.label.toUpperCase()}
{g.entries.map(f => { const isPending = f.status === "pending"; const isEditPending = isPending && !!f.originalData; const displayTo = isPending && f.originalData ? f.originalData.dateTo : f.dateTo; const isPast = displayTo < today; return (
{isEditPending ? ( <>
{formatDate(f.originalData.dateFrom)} – {formatDate(f.originalData.dateTo)}
{formatDate(f.dateFrom)} – {formatDate(f.dateTo)} neu
) : (
{formatDate(f.dateFrom)} – {formatDate(f.dateTo)}
)} {f.note &&
{f.note}
}
{isEditPending ? "Änderung" : isPending ? "pendent" : "bestätigt"} {!isPast && !isPending && ( )} {isPast ? vergangen : isEditPending ? : }
); })}
)); })()}
)} {/* Modal: Ferienantrag */} {antragModal === "ferien" && ( setAntragModal(null)} onSave={saveAntrag} overflow> {employees.length > 1 && ( )}
setAntragForm({ ...antragForm, dateFrom: e.target.value })} /> setAntragForm({ ...antragForm, dateTo: e.target.value })} />
setAntragForm({ ...antragForm, note: e.target.value })} />
)}
{employees.length > 0 && (
MITARBEITER
{(() => { const canSwitchEmployee = isAdmin || (currentUser?.permissions || []).includes("mitarbeiter"); if (canSwitchEmployee) { return ( ); } return (
{employees.find(e => e.id === myEmployeeId)?.name || "—"}
); })()}
)}
{ const d = new Date(viewDate); d.setMonth(d.getMonth() - 1); setCurrentDate(d.toISOString().slice(0, 10)); }} onNext={() => { const d = new Date(viewDate); d.setMonth(d.getMonth() + 1); setCurrentDate(d.toISOString().slice(0, 10)); }} />
{monthLabel}
{["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].map(d => (
{d}
))}
{monthCells.map((cell, i) => { if (!cell) return
; const isSelected = cell.dateStr === currentDate; const isCurToday = cell.dateStr === new Date().toISOString().slice(0, 10); const hasEntries = cell.mins > 0; const noWork = cell.isWeekend || cell.isFeiertag; const isVortag = cell.isVortag && !cell.isFeiertag; let bg = "transparent"; if (isSelected) bg = "#1a1a18"; else if (cell.isFerien) bg = "#e8f5ee"; else if (cell.isPendingFerien) bg = "#fff8e8"; else if (cell.isAbsenz) bg = "#f0f0f0"; else if (cell.isPendingAbsenz) bg = "#f5f0f8"; else if (hasEntries && !noWork) bg = "#faf3e4"; else if (hasEntries && noWork) bg = "#f5f0e8"; const textColor = isSelected ? "#e8e5df" : (noWork && !hasEntries) ? "#ccc" : "#1a1a18"; return ( ); })}
Monatstotal {formatHours(monthTotalMins)}
{selectedEmp && (
Saldo seit Eintritt
{totalSaldo && [ { label: "Soll bis heute", value: `${totalSaldo.soll}h` }, { label: "IST", value: `${totalSaldo.ist}h` }, ].map(r => (
{r.label}{r.value}
))} {totalSaldo && (
Saldo = 0 ? "#2d6a4f" : "#8a1a1a" }}> {totalSaldo.saldo === 0 ? "0h" : `${totalSaldo.saldo > 0 ? "+" : ""}${totalSaldo.saldo}h`}
)}
Monatssaldo — {new Date(currentDate + "T12:00:00").toLocaleString("de-CH", { month: "long" })}
{monthSaldo && [ { label: "Soll", value: `${monthSaldo.soll}h` }, { label: "IST", value: `${monthSaldo.ist}h` }, ].map(r => (
{r.label}{r.value}
))} {monthSaldo && (
Saldo = 0 ? "#2d6a4f" : "#8a1a1a" }}> {monthSaldo.saldo === 0 ? "0h" : `${monthSaldo.saldo > 0 ? "+" : ""}${monthSaldo.saldo}h`}
)}
Ferien {new Date().getFullYear()}
{ferienSaldo && [ { label: "Anspruch", value: `${ferienSaldo.anspruch}h`, show: true }, { label: "Übertrag", value: `${ferienSaldo.ubertrag}h`, show: ferienSaldo.ubertrag > 0 }, { label: "Bezogen", value: `${ferienSaldo.bezogen}h`, show: ferienSaldo.bezogen > 0 }, { label: "Geplant", value: `${ferienSaldo.geplant}h`, show: ferienSaldo.geplant > 0 }, ].filter(r => r.show).map(r => (
{r.label}{r.value}
))} {ferienSaldo && (
Rest = 0 ? "#2d6a4f" : "#8a1a1a" }}> {ferienSaldo.rest === 0 ? "0h" : `${ferienSaldo.rest > 0 ? "+" : ""}${ferienSaldo.rest}h`}
)}
)}
setReceiptView(null)} /> {contextMenu && ( <>
setContextMenu(null)} onContextMenu={ev => { ev.preventDefault(); setContextMenu(null); }} />
)}
); }