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

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

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

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

1539 lines
104 KiB
React
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useRef, useEffect } from "react";
import { SIA_PHASES, 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 (
<div>
<Header title="Zeiterfassung" action={selectedEmp ? (
<div style={{ display: "flex", gap: 8 }}>
<button className="btn btn-ghost" onClick={() => setPrintContent({ type: "timeReport", employee: selectedEmp, entries: (data.timeEntries || []).filter(e => e.employeeId === selectedEmpId), month: currentDate.slice(0, 7), data, settings: data.settings })}>PDF Stunden</button>
<button className="btn btn-ghost" onClick={() => setFerienUebersicht(true)}>Übersicht </button>
<button className="btn btn-primary" onClick={openFerienAntrag}>Ferien beantragen</button>
</div>
) : null} />
<div className="responsive-grid-2" style={{ display: "grid", gridTemplateColumns: "1fr 320px", gap: 20, alignItems: "start" }}>
{/* Tages-Erfassung */}
<div style={{ position: "relative" }}>
{!selectedEmpId && isAdmin && (
<div style={{ position: "absolute", inset: 0, background: "rgba(250,248,245,0.88)", zIndex: 10, borderRadius: 8, display: "flex", alignItems: "center", justifyContent: "center", backdropFilter: "blur(1px)" }}>
<div style={{ textAlign: "center", color: "#888" }}>
{employees.length === 0 ? (
<>
<div style={{ fontSize: 13, fontWeight: 500, color: "#555" }}>Noch keine Mitarbeiter erfasst</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 4 }}>Mitarbeiter unter «Mitarbeiter» anlegen</div>
</>
) : (
<>
<div style={{ fontSize: 13, fontWeight: 500, color: "#555" }}>Bitte zuerst einen Mitarbeiter wählen</div>
<div style={{ fontSize: 11, color: "#aaa", marginTop: 4 }}>Mitarbeiterwahl rechts </div>
</>
)}
</div>
</div>
)}
<div className="card" style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 8, padding: "12px 16px" }}>
<NavArrows onPrev={() => viewMode === "tag" ? changeDate(-1) : changeWeek(-1)} onNext={() => viewMode === "tag" ? changeDate(1) : changeWeek(1)} />
<div ref={navCal.ref} style={{ flex: 1, textAlign: "center", position: "relative" }}>
{viewMode === "tag" ? (
<div onClick={() => navCal.setOpen(o => !o)} style={{ cursor: "pointer", display: "inline-block" }} title="Datum wählen">
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 18, fontWeight: 400 }}>
{`${weekdayLabel}, ${new Date(currentDate).toLocaleDateString("de-CH", { day: "numeric", month: "long" })}`}
</div>
</div>
) : (
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 18, fontWeight: 400 }}>{weekLabel}</div>
)}
{navCal.open && (
<CalendarPopup
value={currentDate}
onChange={ds => { setCurrentDate(ds); navCal.setOpen(false); }}
onClose={() => navCal.setOpen(false)}
showClear={false}
/>
)}
</div>
{!isToday && <button className="btn btn-ghost" onClick={goToday} style={{ fontSize: 11 }}>Heute</button>}
<div style={{ display: "flex", border: "1px solid var(--border2)", borderRadius: 20, overflow: "hidden", marginLeft: 4 }}>
{[["tag", "Tag"], ["woche", "Woche"]].map(([mode, label], i) => (
<React.Fragment key={mode}>
{i > 0 && <div style={{ width: 1, background: "var(--border2)", alignSelf: "stretch" }} />}
<button onClick={() => { setViewMode(mode); setWeekSelectStart(null); setWeekForm(null); setWeekAbsForm(null); setWeekEditForm(null); }} style={{
fontSize: 11, padding: "0 12px", height: 30, border: "none", cursor: "pointer", fontFamily: "inherit",
background: viewMode === mode ? "var(--text)" : "transparent",
color: viewMode === mode ? "var(--bg)" : "var(--text3)", transition: "all 0.15s",
}}>{label}</button>
</React.Fragment>
))}
</div>
</div>
{viewMode === "tag" ? (
<>
<div className="card" style={{ padding: 0 }}>
<table style={{ width: "100%" }}>
<thead>
<tr>
<th style={{ width: "24%" }}>Projekt</th>
<th style={{ width: 130 }}>Phase / Position</th>
<th>Tätigkeit</th>
<th style={{ width: 80 }}>Std</th>
<th style={{ width: 80 }}>Min</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{dayEntries.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: "center", color: "#aaa", padding: 28 }}>Keine Einträge an diesem Tag</td></tr>
)}
{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 (
<tr key={e.id} style={isInvoiced ? { opacity: 0.6, background: "#f5f2ed" } : {}}>
<td style={{ padding: "6px 8px" }}>
<select disabled={disabled} value={e.projectId} onChange={ev => { const proj = data.projects.find(p => p.id === ev.target.value); const { phaseId, positionId } = getFirstPhaseForProject(proj); updateEntry(e.id, { projectId: ev.target.value, phaseId, positionId }); }} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }}>
<option value=""></option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</td>
<td style={{ padding: "6px 8px" }}>
<select disabled={disabled || !hasAnyPhase} value={combinedVal} onChange={ev => onCombinedChange(ev.target.value)} style={{ border: `1px solid ${hasAnyPhase && !combinedVal && !disabled ? "#b5621e" : "#e0dbd4"}`, height: 32, fontSize: 12, color: e.positionId ? "#7a6a00" : "inherit", fontWeight: e.positionId ? 600 : 400, opacity: !hasAnyPhase ? 0.4 : 1 }}>
<option value=""></option>
{customPhases.length > 0 && (enabledPhases.length > 0 || positions.length > 0) && (<optgroup label="Eigene Phasen">{customPhases.map(cp => <option key={cp.id} value={cp.id}>{cp.label}</option>)}</optgroup>)}
{customPhases.length > 0 && enabledPhases.length === 0 && positions.length === 0 && customPhases.map(cp => <option key={cp.id} value={cp.id}>{cp.label}</option>)}
{positions.length === 0 && enabledPhases.map(ph => <option key={ph.id} value={ph.id}>{ph.id} · {ph.label.split(" ").slice(1).join(" ")}</option>)}
{positions.length > 0 && (<>{enabledPhases.length > 0 && (<optgroup label="Hauptauftrag">{enabledPhases.map(ph => <option key={ph.id} value={ph.id}>{ph.id} · {ph.label.split(" ").slice(1).join(" ")}</option>)}</optgroup>)}{positions.map(pos => { const pp = (pos.enabledPhases||[]).map(id=>SIA_PHASES.find(ph=>ph.id===id)).filter(Boolean); if(!pp.length) return null; return <optgroup key={pos.code} label={pos.code+(pos.label?" · "+pos.label:"")}>{pp.map(ph=><option key={ph.id} value={ph.id+"|"+pos.code}>{ph.id} · {ph.label.split(" ").slice(1).join(" ")}</option>)}</optgroup>; })}</>)}
</select>
</td>
<td style={{ padding: "6px 8px" }}>
<input disabled={disabled} value={e.description || ""} onChange={ev => updateEntry(e.id, { description: ev.target.value })} placeholder="Was wurde gemacht?" style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} />
</td>
<td style={{ padding: "6px 8px" }}>
<input disabled={disabled} type="number" min={0} max={24} value={Math.floor((e.minutes || 0) / 60)} onChange={ev => updateEntry(e.id, { minutes: (+ev.target.value * 60) + ((e.minutes || 0) % 60) })} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} />
</td>
<td style={{ padding: "6px 8px" }}>
<select disabled={disabled} value={(e.minutes || 0) % 60} onChange={ev => updateEntry(e.id, { minutes: Math.floor((e.minutes || 0) / 60) * 60 + +ev.target.value })} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }}>
{[0, 15, 30, 45].map(m => <option key={m} value={m}>{m}</option>)}
</select>
</td>
<td style={{ padding: "6px 8px", textAlign: "right" }}>
<button disabled={disabled} className="btn btn-sm btn-danger" onClick={() => delEntry(e.id)}><span className="material-icons" style={{fontSize:16,verticalAlign:"middle"}}>close</span></button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td colSpan={3} style={{ padding: "12px 12px" }}>
<button className="btn btn-ghost" onClick={addEntry} style={{ fontSize: 12 }}>+ Zeile hinzufügen</button>
</td>
<td colSpan={2} style={{ padding: "12px 8px", textAlign: "right", fontSize: 13 }}>
<span style={{ color: "#888" }}>Tagestotal:&nbsp;</span><strong>{formatHours(dayTotalMins)}</strong>
</td>
<td></td>
</tr>
{selectedEmp && (daySoll > 0 || dayTotalMins > 0) && (
<tr style={{ borderTop: "1px solid #ece8e2", background: "#faf8f5" }}>
<td colSpan={3} style={{ padding: "6px 12px", fontSize: 11, color: "#888" }}>{selectedEmp.name}{daySoll === 0 ? " · Wochenende / Feiertag" : " · Soll heute"}</td>
<td colSpan={2} style={{ padding: "6px 8px", textAlign: "right", fontSize: 12 }}>
{daySoll > 0 ? (<><span style={{ color: "#888" }}>{Math.round(daySoll * 10) / 10}h Soll&nbsp;·&nbsp;</span><span style={{ fontWeight: 600, color: dayDiff === 0 ? "#555" : dayDiff > 0 ? "#2d6a4f" : "#8a1a1a" }}>{dayDiff === 0 ? "±0h" : `${dayDiff > 0 ? "+" : ""}${dayDiff}h`}</span></>) : (<span style={{ fontWeight: 600, color: "#b07848" }}>{Math.round(dayIst * 10) / 10}h</span>)}
</td>
<td></td>
</tr>
)}
</tfoot>
</table>
</div>
{dayAbs.length > 0 && (
<div className="card" style={{ marginTop: 16, padding: 0 }}>
<div style={{ padding: "10px 16px", borderBottom: "1px solid #ece8e2", fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>ABSENZEN</div>
<table style={{ fontSize: 12 }}>
<thead><tr><th style={{ width: "22%" }}>Typ</th><th>Bemerkung</th><th style={{ width: 70 }}>Std</th><th style={{ width: 70 }}>Min</th><th style={{ width: 40 }}></th></tr></thead>
<tbody>
{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 (
<tr key={a.id}>
<td style={{ padding: "6px 8px" }}>
<select value={a.type || ""} onChange={ev => updateAbs(a.id, { type: ev.target.value })} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }}>
{absenzTypes.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</td>
<td style={{ padding: "6px 8px" }}>
<input value={a.note || ""} onChange={ev => updateAbs(a.id, { note: ev.target.value })} placeholder="Bemerkung…" style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} />
</td>
<td style={{ padding: "6px 8px" }}>
<input type="number" min={0} max={24} value={absH} onChange={ev => updateAbs(a.id, { hours: +ev.target.value, minutes: absM })} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} />
</td>
<td style={{ padding: "6px 8px" }}>
<select value={absM} onChange={ev => updateAbs(a.id, { hours: absH, minutes: +ev.target.value })} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }}>
{[0, 15, 30, 45].map(m => <option key={m} value={m}>{m}</option>)}
</select>
</td>
<td style={{ padding: "6px 8px", textAlign: "right" }}>
<button className="btn btn-danger" style={{ padding: "0 8px", height: 28, fontSize: 12 }} onClick={() => delAbs(a.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button>
</td>
</tr>
);
})}
</tbody>
<tfoot><tr><td colSpan={4} style={{ padding: "10px 12px" }}><button className="btn btn-ghost" onClick={addAbs} style={{ fontSize: 12 }}>+ Zeile hinzufügen</button></td><td></td></tr></tfoot>
</table>
</div>
)}
{dayAbs.length === 0 && (
<div style={{ marginTop: 10, display: "flex", gap: 6, justifyContent: "flex-end" }}>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px", background: "#fff" }} onClick={addAbs}>+ Absenz erfassen</button>
</div>
)}
{dayExpenses.length > 0 && (
<div className="card" style={{ marginTop: 16, padding: 0 }}>
<div style={{ padding: "10px 16px", borderBottom: "1px solid #ece8e2", fontSize: 11, letterSpacing: "0.1em", color: "#888" }}>SPESEN</div>
<input ref={expReceiptInputRef} type="file" accept="image/*,application/pdf" onChange={handleExpReceiptUpload} style={{ display: "none" }} />
<table style={{ fontSize: 12 }}>
<thead><tr><th style={{ width: "22%" }}>Kategorie</th><th style={{ width: "20%" }}>Projekt</th><th>Beschreibung</th><th style={{ width: 90 }}>Betrag CHF</th><th style={{ width: 36, textAlign: "center" }}>Beleg</th><th style={{ width: 40 }}></th></tr></thead>
<tbody>
{dayExpenses.map(e => (
<tr key={e.id}>
<td style={{ padding: "6px 8px" }}><select value={e.category} onChange={ev => updateExp(e.id, { category: ev.target.value })} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }}>{(data.settings.expenseCategories || EXPENSE_CATEGORIES).map(c => <option key={c}>{c}</option>)}</select></td>
<td style={{ padding: "6px 8px" }}><select value={e.projectId || ""} onChange={ev => updateExp(e.id, { projectId: ev.target.value })} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }}><option value=""> kein Projekt </option>{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}</select></td>
<td style={{ padding: "6px 8px" }}><input value={e.description || ""} onChange={ev => updateExp(e.id, { description: ev.target.value })} placeholder="Beschreibung…" style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} /></td>
<td style={{ padding: "6px 8px" }}><input type="number" step="0.05" min={0} value={e.amount || ""} onChange={ev => updateExp(e.id, { amount: +ev.target.value })} style={{ border: "1px solid #e0dbd4", height: 32, fontSize: 12 }} /></td>
<td style={{ padding: "6px 8px", textAlign: "center" }}>
{e.receiptData ? (
<button className="btn btn-ghost" title={e.receiptName || "Beleg anzeigen"} onClick={() => setReceiptView(e)} style={{ padding: "0 6px", height: 28 }}>
<span className="material-icons" style={{ fontSize: 16, verticalAlign: "middle", color: "#2d6a4f" }}>receipt_long</span>
</button>
) : (
<button className="btn btn-ghost" title="Beleg hochladen" onClick={() => { setUploadingExpId(e.id); expReceiptInputRef.current?.click(); }} style={{ padding: "0 6px", height: 28 }}>
<span className="material-icons" style={{ fontSize: 16, verticalAlign: "middle", color: "#bbb" }}>upload</span>
</button>
)}
</td>
<td style={{ padding: "6px 8px", textAlign: "right" }}><button className="btn btn-danger" style={{ padding: "0 8px", height: 28, fontSize: 12 }} onClick={() => delExp(e.id)}><span className="material-icons" style={{ fontSize: 16 }}>close</span></button></td>
</tr>
))}
</tbody>
<tfoot><tr><td colSpan={4} style={{ padding: "10px 12px" }}><button className="btn btn-ghost" onClick={addExp} style={{ fontSize: 12 }}>+ Zeile hinzufügen</button></td><td style={{ padding: "10px 8px", textAlign: "right", fontSize: 13 }}><span style={{ color: "#888" }}>Total:&nbsp;</span><strong>{(dayExpenses.reduce((s, e) => s + (e.amount || 0), 0)).toFixed(2)}</strong></td><td></td></tr></tfoot>
</table>
</div>
)}
{dayExpenses.length === 0 && (
<div style={{ marginTop: 10, display: "flex", gap: 6, justifyContent: "flex-end" }}>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: "4px 12px", background: "#fff" }} onClick={addExp}>+ Spese erfassen</button>
</div>
)}
</>
) : (
/* WOCHENANSICHT */
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
{void resizeTick /* trigger re-render during resize */}
{resizingRef.current && <style>{`* { cursor: ${resizingRef.current.edge === "move" ? "grabbing" : "ns-resize"} !important; user-select: none !important; }`}</style>}
{/* Zeitraster mit sticky Tageskopf */}
<div ref={weekGridRef} style={{ isolation: "isolate", overflowY: "auto", maxHeight: "calc(100vh - 260px)" }} onMouseLeave={() => setWeekHover(null)}>
{/* Sticky Tageskopf */}
<div style={{ display: "flex", position: "sticky", top: 0, zIndex: 20, background: "#fff", borderBottom: "2px solid #e8e3dc", transform: "translateZ(0)", willChange: "transform" }}>
<div style={{ width: 44, flexShrink: 0 }} />
{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 (
<div key={ds} style={{ flex: 1, textAlign: "center", padding: "8px 4px 6px", position: "relative", borderLeft: "1px solid #ece8e2", cursor: "pointer", background: isCurDay ? "#1a1a18" : isVacDay ? ((!isPast && Math.ceil((new Date(ds) - new Date(todayStr)) / 86400000) > 21) ? "#e8f0fa" : "#e8f5ee") : isPendingVacDay ? "#fff8e8" : isFeiertag ? "#f5f2ed" : "#fff" }}
onClick={() => { setCurrentDate(ds); setViewMode("tag"); }}>
<div style={{ fontSize: 9, letterSpacing: "0.1em", color: isCurDay ? "#e8e5df" : isBeforeEintritt ? "#ddd" : "#aaa" }}>{d.toLocaleDateString("de-CH", { weekday: "short" }).toUpperCase()}</div>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 18, lineHeight: 1.2, color: isCurDay ? "#e8e5df" : isBeforeEintritt ? "#ccc" : isThisToday ? "var(--accent)" : isFeiertag ? "#bbb" : "#1a1a18" }}>{d.getDate()}</div>
<div style={{ position: "absolute", top: 8, right: 4, textAlign: "right", lineHeight: 1.2 }}>
{(() => {
if (!selectedEmp || noWork || isBeforeEintritt) return wkTotal > 0 ? <div style={{ fontSize: 9, fontWeight: 500, color: isCurDay ? "#e8e5df" : "#8a6a3a" }}>{istH.toFixed(1)}h</div> : 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 <div style={{ fontSize: 8, fontWeight: 600, color }}>Ferien</div>;
}
if (isPendingVacDay && wkTotal === 0) return <div style={{ fontSize: 8, fontWeight: 600, color: isCurDay ? "#e8e5df" : "#b5621e" }}>Ferien?</div>;
if (!isPast) return wkTotal > 0 ? <div style={{ fontSize: 9, fontWeight: 500, color: isCurDay ? "#e8e5df" : "#8a6a3a" }}>{istH.toFixed(1)}h</div> : 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 <div style={{ fontSize: 9, fontWeight: 600, color: saldoColor }}>{saldoText}</div>;
})()}
</div>
</div>
);
})}
</div>
{/* Zeitgitter */}
<div style={{ display: "flex" }}>
<div style={{ width: 44, flexShrink: 0, pointerEvents: "none" }}>
{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 (
<div key={i} style={{ height: SLOT_H, display: "flex", alignItems: "flex-start", justifyContent: "flex-end", paddingRight: 6, paddingTop: 2, fontSize: 10, color: labelColor, lineHeight: 1, boxSizing: "border-box", borderTop: isHourMark ? `1px solid ${isOffHour ? "#e8eef2" : "#f0ede8"}` : "none", fontWeight: isOffHour ? 400 : 500 }}>
{isHourMark ? slotToTime(i) : ""}
</div>
);
})}
</div>
{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 (
<div key={ds} style={{ flex: 1, position: "relative", borderLeft: "1px solid #ece8e2", background: maiTagBlocked || colBeforeEintritt ? "#faf8f5" : isFeiertag ? "#f7f5f1" : "#fff" }}>
{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 (
<div key={slotIdx} style={{ height: SLOT_H, boxSizing: "border-box", borderTop: slotIdx > 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 (
<div key={e.id} title={`${proj?.name||"—"}: ${(dispMins/60).toFixed(1)}h${e.description?" · "+e.description:""}`}
onContextMenu={ev => { 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" }}>
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: handleH, cursor: "ns-resize", background: "rgba(0,0,0,0.15)", borderRadius: "3px 3px 0 0", zIndex: 1 }} onMouseDown={ev => startResizing(ev, "top")} />
<div style={{ position: "absolute", top: handleH, left: 0, right: 0, bottom: handleH, cursor: isMoving ? "grabbing" : "grab", zIndex: 1 }} onMouseDown={ev => startResizing(ev, "move")} />
<div style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", paddingTop: 1, pointerEvents: "none" }}>{proj?.name||"—"}</div>
{phaseDisplay && height > 24 && <div style={{ fontSize: 7, opacity: 0.8, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", marginTop: 0, pointerEvents: "none" }}>{phaseDisplay}</div>}
{height > 36 && <div style={{ fontSize: 8, opacity: 0.85, marginTop: 1, pointerEvents: "none" }}>{slotToTime((sh*60+sm-SLOT_START_H*60)/15)} {slotToTime((sh*60+sm-SLOT_START_H*60)/15 + dispMins/15)}</div>}
{height > 52 && e.description && <div style={{ opacity: 0.75, fontSize: 8, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", pointerEvents: "none" }}>{e.description}</div>}
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: handleH, cursor: "ns-resize", background: "rgba(0,0,0,0.15)", borderRadius: "0 0 3px 3px", zIndex: 1 }} onMouseDown={ev => startResizing(ev, "bottom")} />
</div>
);
})}
{/* 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 (
<div key={a.id} title={`${t?.label || a.type}: ${(dispMinsA/60).toFixed(1)}h${a.note ? " · " + a.note : ""}`}
style={{ position: "absolute", top: topA, left: 2, right: 2, height: heightA, background: bg, opacity: 0.85, borderRadius: 3, padding: `${handleHA}px 5px`, fontSize: 9, color: "#fff", overflow: "hidden", zIndex: isResA ? 10 : 2, boxSizing: "border-box", userSelect: "none" }}>
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: handleHA, cursor: "ns-resize", background: "rgba(0,0,0,0.15)", borderRadius: "3px 3px 0 0", zIndex: 1 }} onMouseDown={ev => startResizingA(ev, "top")} />
<div style={{ position: "absolute", top: handleHA, left: 0, right: 0, bottom: handleHA, cursor: isMovingA ? "grabbing" : "grab", zIndex: 1 }} onMouseDown={ev => startResizingA(ev, "move")} />
<div style={{ fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", pointerEvents: "none" }}>{t?.label || a.type}</div>
{heightA > 28 && <div style={{ fontSize: 8, opacity: 0.85, marginTop: 1, pointerEvents: "none" }}>{dispStartA} {slotToTime((shA*60+smA-SLOT_START_H*60)/15 + dispMinsA/15)}</div>}
{heightA > 44 && a.note && <div style={{ opacity: 0.75, fontSize: 8, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", pointerEvents: "none" }}>{a.note}</div>}
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, height: handleHA, cursor: "ns-resize", background: "rgba(0,0,0,0.15)", borderRadius: "0 0 3px 3px", zIndex: 1 }} onMouseDown={ev => startResizingA(ev, "bottom")} />
</div>
);
})}
{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 <div style={{ position: "absolute", left: 0, right: 0, top, height: 2, background: "var(--accent)", zIndex: 5, pointerEvents: "none" }} />;
})()}
</div>
);
})}
</div>{/* end display:flex time grid */}
</div>{/* end scroll container */}
{weekSelectStart && !weekForm && (
<div style={{ borderTop: "1px solid #ece8e2", padding: "8px 16px 8px 56px", fontSize: 11, color: "#888", background: "#faf8f5", display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ color: "var(--accent)", fontWeight: 600 }}>{slotToTime(weekSelectStart.slotIdx)}</span>
<span>ausgewählt Endzeit anklicken</span>
<button className="btn btn-ghost" onClick={() => setWeekSelectStart(null)} style={{ marginLeft: "auto", fontSize: 11, padding: "2px 8px" }}>Abbrechen</button>
</div>
)}
{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 (
<div style={{ borderTop: `2px solid ${accentColor}`, padding: "12px 16px", background: "#faf8f5" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 10 }}>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: accentColor, fontWeight: 600 }}>
{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`}
</div>
<div style={{ display: "flex", gap: 4, marginLeft: "auto", flexShrink: 0 }}>
{[["projekt", "Projekt"], ["absenz", "Intern / Absenz"]].map(([m, label]) => (
<button key={m} className={`pill${weekForm.mode === m ? " active" : ""}`} onClick={() => setWeekForm({ ...weekForm, mode: m })}>{label}</button>
))}
</div>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "flex-end" }}>
{isAbsMode ? (
<>
<div style={{ flex: "1 1 180px" }}>
<div style={{ fontSize: 10, color: "#aaa", marginBottom: 3, letterSpacing: "0.06em" }}>TYP</div>
<select value={weekForm.absType || ""} onChange={e => setWeekForm({ ...weekForm, absType: e.target.value })} style={{ height: 34, fontSize: 12, width: "100%" }} autoFocus>
<option value=""> wählen </option>
{absenzTypes.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</div>
<div style={{ flex: "2 1 180px" }}>
<div style={{ fontSize: 10, color: "#aaa", marginBottom: 3, letterSpacing: "0.06em" }}>NOTIZ</div>
<input value={weekForm.absNote || ""} onChange={e => setWeekForm({ ...weekForm, absNote: e.target.value })} placeholder="Notiz…" style={{ height: 34, fontSize: 12, width: "100%", boxSizing: "border-box" }} onKeyDown={e => e.key === "Enter" && saveWeekEntry()} />
</div>
</>
) : (
<>
<div style={{ flex: "1 1 150px" }}>
<div style={{ fontSize: 10, color: "#aaa", marginBottom: 3, letterSpacing: "0.06em" }}>PROJEKT</div>
<select value={weekForm.projectId} onChange={e => { const proj = data.projects.find(p => p.id === e.target.value); const { phaseId, positionId } = getFirstPhaseForProject(proj); setWeekForm({...weekForm, projectId:e.target.value, phaseId, positionId}); }} style={{ height: 34, fontSize: 12, width: "100%" }} autoFocus>
<option value=""> wählen </option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{hasPhase && (
<div style={{ flex: "1 1 120px" }}>
<div style={{ fontSize: 10, color: !combinedVal ? "#b5621e" : "#aaa", marginBottom: 3, letterSpacing: "0.06em" }}>PHASE *</div>
<select value={combinedVal} onChange={e => onPhaseChange(e.target.value)} style={{ height: 34, fontSize: 12, width: "100%", borderColor: !combinedVal ? "#b5621e" : undefined }}>
<option value=""> wählen </option>
{customPhases.map(cp => <option key={cp.id} value={cp.id}>{cp.label}</option>)}
{enabledPhases.map(ph => <option key={ph.id} value={ph.id}>{ph.id} · {ph.label.split(" ").slice(1).join(" ")}</option>)}
{positions.flatMap(pos => (pos.enabledPhases||[]).map(id=>SIA_PHASES.find(ph=>ph.id===id)).filter(Boolean).map(ph => <option key={ph.id+"|"+pos.code} value={ph.id+"|"+pos.code}>{pos.code} · {ph.id}</option>))}
</select>
</div>
)}
<div style={{ flex: "2 1 180px" }}>
<div style={{ fontSize: 10, color: "#aaa", marginBottom: 3, letterSpacing: "0.06em" }}>TÄTIGKEIT</div>
<input value={weekForm.description} onChange={e => setWeekForm({...weekForm, description:e.target.value})} placeholder="Beschreibung…" style={{ height: 34, fontSize: 12, width: "100%", boxSizing: "border-box" }} onKeyDown={e => e.key === "Enter" && saveWeekEntry()} />
</div>
</>
)}
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-primary" onClick={saveWeekEntry} disabled={isAbsMode ? !weekForm.absType : (!weekForm.projectId || (hasPhase && !combinedVal))} style={{ fontSize: 12, height: 34 }}>Speichern</button>
<button className="btn btn-ghost" onClick={() => { setWeekForm(null); setWeekSelectStart(null); }} style={{ fontSize: 12, height: 34 }}><span className="material-icons" style={{fontSize:16,verticalAlign:"middle"}}>close</span></button>
</div>
</div>
</div>
);
})()}
{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 (
<div style={{ borderTop: `2px solid ${accentColor}`, padding: "12px 16px", background: "#faf8f5" }}>
<div style={{ fontSize: 10, color: accentColor, fontWeight: 600, letterSpacing: "0.1em", marginBottom: 10 }}>
{new Date(a.date).toLocaleDateString("de-CH", { weekday: "long", day: "numeric", month: "long" }).toUpperCase()} · {a.startTime} {a.endTime} · {(durE/60).toFixed(1)}h
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "flex-end" }}>
<div style={{ flex: "1 1 180px" }}>
<div style={{ fontSize: 10, color: "#aaa", marginBottom: 3, letterSpacing: "0.06em" }}>TYP</div>
<select value={a.type || ""} onChange={ev => update("absences", (data.absences||[]).map(x => x.id === a.id ? {...x, type: ev.target.value} : x))} style={{ height: 34, fontSize: 12, width: "100%" }} autoFocus>
<option value=""> wählen </option>
{absenzTypes.map(at => <option key={at.id} value={at.id}>{at.label}</option>)}
</select>
</div>
<div style={{ flex: "2 1 180px" }}>
<div style={{ fontSize: 10, color: "#aaa", marginBottom: 3, letterSpacing: "0.06em" }}>NOTIZ</div>
<input value={a.note || ""} onChange={ev => 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" }} />
</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-danger" onClick={() => { update("absences", (data.absences||[]).filter(x => x.id !== a.id)); setWeekEditForm(null); }} style={{ fontSize: 12, height: 34 }}>Löschen</button>
<button className="btn btn-ghost" onClick={() => setWeekEditForm(null)} style={{ fontSize: 12, height: 34 }}><span className="material-icons" style={{fontSize:16,verticalAlign:"middle"}}>close</span></button>
</div>
</div>
</div>
);
} 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 (
<div style={{ borderTop: `2px solid ${bgE}`, padding: "12px 16px", background: "#faf8f5" }}>
<div style={{ fontSize: 10, color: bgE, fontWeight: 600, letterSpacing: "0.1em", marginBottom: 10, display: "flex", alignItems: "center", gap: 8 }}>
<span>{new Date(e.date).toLocaleDateString("de-CH", { weekday: "long", day: "numeric", month: "long" }).toUpperCase()} · {e.startTime} {e.endTime} · {(durE/60).toFixed(1)}h</span>
{isInvoiced && <span style={{ fontSize: 9, color: "#b5621e", fontWeight: 400 }}>verrechnet</span>}
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "flex-end" }}>
<div style={{ flex: "1 1 150px" }}>
<div style={{ fontSize: 10, color: "#aaa", marginBottom: 3, letterSpacing: "0.06em" }}>PROJEKT</div>
<select disabled={disabledE} value={e.projectId} onChange={ev => updateEntry(e.id, { projectId: ev.target.value, phaseId: "", positionId: "" })} style={{ height: 34, fontSize: 12, width: "100%" }} autoFocus>
<option value=""></option>
{data.projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
{hasPhaseE && (
<div style={{ flex: "1 1 120px" }}>
<div style={{ fontSize: 10, color: "#aaa", marginBottom: 3, letterSpacing: "0.06em" }}>PHASE</div>
<select disabled={disabledE} value={combinedValE} onChange={ev => onPhaseChangeE(ev.target.value)} style={{ height: 34, fontSize: 12, width: "100%" }}>
<option value=""></option>
{customPhasesE.map(cp => <option key={cp.id} value={cp.id}>{cp.label}</option>)}
{enabledPhasesE.map(ph => <option key={ph.id} value={ph.id}>{ph.id} · {ph.label.split(" ").slice(1).join(" ")}</option>)}
{positionsE.flatMap(pos => (pos.enabledPhases||[]).map(id=>SIA_PHASES.find(ph=>ph.id===id)).filter(Boolean).map(ph => <option key={ph.id+"|"+pos.code} value={ph.id+"|"+pos.code}>{pos.code} · {ph.id}</option>))}
</select>
</div>
)}
<div style={{ flex: "2 1 180px" }}>
<div style={{ fontSize: 10, color: "#aaa", marginBottom: 3, letterSpacing: "0.06em" }}>TÄTIGKEIT</div>
<input disabled={disabledE} value={e.description || ""} onChange={ev => updateEntry(e.id, { description: ev.target.value })} placeholder="Beschreibung…" style={{ height: 34, fontSize: 12, width: "100%", boxSizing: "border-box" }} />
</div>
<div style={{ display: "flex", gap: 6 }}>
<button className="btn btn-danger" disabled={disabledE} onClick={() => { delEntry(e.id); setWeekEditForm(null); }} style={{ fontSize: 12, height: 34, opacity: disabledE ? 0.4 : 1 }}>Löschen</button>
<button className="btn btn-ghost" onClick={() => setWeekEditForm(null)} style={{ fontSize: 12, height: 34 }}><span className="material-icons" style={{fontSize:16,verticalAlign:"middle"}}>close</span></button>
</div>
</div>
</div>
);
}
})()}
</div>
)}
</div>
{/* Modal: Ferien-Übersicht */}
{ferienUebersicht && selectedEmp && (
<Modal title={`Ferien — ${selectedEmp.name}`} onClose={() => 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 <div style={{ fontSize: 13, color: "#aaa", padding: "12px 0" }}>Keine Ferieneinträge vorhanden.</div>;
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 => (
<div key={g.label} style={{ marginBottom: 16 }}>
<div style={{ fontSize: 10, letterSpacing: "0.1em", color: "#888", marginBottom: 8 }}>{g.label.toUpperCase()}</div>
{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 (
<div key={f.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 0", borderBottom: "1px solid #f0ede8" }}>
<div style={{ flex: 1 }}>
{isEditPending ? (
<>
<div style={{ fontSize: 11, color: "#aaa", textDecoration: "line-through" }}>{formatDate(f.originalData.dateFrom)} {formatDate(f.originalData.dateTo)}</div>
<div style={{ fontSize: 13, color: "#222" }}>{formatDate(f.dateFrom)} {formatDate(f.dateTo)} <span style={{ fontSize: 10, color: "#b5621e", fontWeight: 600 }}>neu</span></div>
</>
) : (
<div style={{ fontSize: 13, color: "#222" }}>{formatDate(f.dateFrom)} {formatDate(f.dateTo)}</div>
)}
{f.note && <div style={{ fontSize: 11, color: "#aaa", marginTop: 2 }}>{f.note}</div>}
</div>
<span style={{ fontSize: 10, padding: "2px 10px", borderRadius: 20, background: isPending ? "#fff8e8" : "#e8f5ee", color: isPending ? "#b5621e" : "#2d6a4f", fontWeight: 600, flexShrink: 0, whiteSpace: "nowrap" }}>
{isEditPending ? "Änderung" : isPending ? "pendent" : "bestätigt"}
</span>
{!isPast && !isPending && (
<button onClick={() => { setFerienUebersicht(false); editFerien(f); }} style={{ background: "none", border: "none", color: "#888", cursor: "pointer", fontSize: 13, padding: "0 3px", lineHeight: 1, flexShrink: 0 }} title="Bearbeiten"><span className="material-icons" style={{fontSize:16,verticalAlign:"middle"}}>edit</span></button>
)}
{isPast
? <span style={{ fontSize: 11, color: "#ccc", flexShrink: 0, padding: "0 4px" }}>vergangen</span>
: isEditPending
? <button onClick={() => cancelFerienEdit(f.id)} style={{ background: "none", border: "none", color: "#bbb", cursor: "pointer", fontSize: 13, padding: "0 2px", lineHeight: 1, flexShrink: 0 }} title="Änderung verwerfen"><span className="material-icons" style={{fontSize:16,verticalAlign:"middle"}}>close</span></button>
: <button className="btn btn-sm btn-danger" onClick={() => deleteFerien(f.id)} title="Zurücknehmen"><span className="material-icons" style={{fontSize:16,verticalAlign:"middle"}}>close</span></button>
}
</div>
);
})}
</div>
));
})()}
</Modal>
)}
{/* Modal: Ferienantrag */}
{antragModal === "ferien" && (
<Modal title="Ferien beantragen" onClose={() => setAntragModal(null)} onSave={saveAntrag} overflow>
{employees.length > 1 && (
<FormField label="Mitarbeiter">
<select value={antragForm.employeeId || ""} onChange={e => setAntragForm({ ...antragForm, employeeId: e.target.value })}>
<option value=""> wählen </option>
{employees.map(e => <option key={e.id} value={e.id}>{e.name}</option>)}
</select>
</FormField>
)}
<div className="form-row">
<FormField label="Von"><DatePicker value={antragForm.dateFrom || ""} onChange={e => setAntragForm({ ...antragForm, dateFrom: e.target.value })} /></FormField>
<FormField label="Bis"><DatePicker value={antragForm.dateTo || ""} onChange={e => setAntragForm({ ...antragForm, dateTo: e.target.value })} /></FormField>
</div>
<FormField label="Notiz (optional)"><input value={antragForm.note || ""} onChange={e => setAntragForm({ ...antragForm, note: e.target.value })} /></FormField>
</Modal>
)}
<div>
{employees.length > 0 && (
<div className="card" style={{ marginBottom: 12, padding: "10px 14px" }}>
<div style={{ fontSize: 10, letterSpacing: "0.08em", color: "#888", marginBottom: 6 }}>MITARBEITER</div>
{(() => {
const canSwitchEmployee = isAdmin || (currentUser?.permissions || []).includes("mitarbeiter");
if (canSwitchEmployee) {
return (
<select value={selectedEmpId} onChange={e => setSelectedEmpId(e.target.value)} style={{ width: "100%", fontSize: 13 }}>
{isAdmin && <option value=""> Alle / Kein Filter </option>}
{employees.map(e => <option key={e.id} value={e.id}>{e.name} · {e.pensum||100}%</option>)}
</select>
);
}
return (
<div style={{ fontSize: 13, fontWeight: 500, color: "#1a1a18" }}>
{employees.find(e => e.id === myEmployeeId)?.name || "—"}
</div>
);
})()}
</div>
)}
<div className="card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 }}>
<NavArrows onPrev={() => { 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)); }} />
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 14, textAlign: "center" }}>{monthLabel}</div>
<div style={{ width: 55 }} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 2, marginBottom: 6 }}>
{["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"].map(d => (
<div key={d} style={{ fontSize: 9, color: "#aaa", textAlign: "center", letterSpacing: "0.05em", padding: "4px 0" }}>{d}</div>
))}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 2 }}>
{monthCells.map((cell, i) => {
if (!cell) return <div key={i}></div>;
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 (
<button key={i} onClick={() => setCurrentDate(cell.dateStr)} style={{
aspectRatio: "1", padding: 1,
background: bg, color: textColor,
border: isCurToday && !isSelected ? "1.5px solid var(--accent)" : "1px solid transparent",
borderRadius: 4, fontFamily: "inherit",
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
cursor: "pointer", transition: "all 0.1s",
}}>
<div style={{ fontSize: 11, fontWeight: noWork ? 400 : 500 }}>{cell.day}</div>
{!selectedEmp && hasEntries && (
<div style={{ fontSize: 7, color: isSelected ? "#e8e5df" : "#8a6a3a", marginTop: 1 }}>
{(cell.mins / 60).toFixed(1)}h
</div>
)}
{selectedEmp && hasEntries && (
<div style={{ fontSize: 7, color: isSelected ? "#e8e5df" : "#2d6a4f", fontWeight: 600, marginTop: 1 }}>
{(cell.mins / 60).toFixed(1)}h
</div>
)}
{(cell.isFerien || cell.isPendingFerien) && !isSelected && (
<div style={{ marginTop: 2, display: "flex", justifyContent: "center" }}>
{cell.isPendingFerien ? (
<div style={{ fontSize: 7, color: "#b5621e", lineHeight: 1 }}>?</div>
) : (
<svg width="7" height="7" viewBox="0 0 12 12" fill="none">
<circle cx="6" cy="6" r="2.5" fill="#2d6a4f"/>
<line x1="6" y1="0" x2="6" y2="2" stroke="#2d6a4f" strokeWidth="1.2" strokeLinecap="round"/>
<line x1="6" y1="10" x2="6" y2="12" stroke="#2d6a4f" strokeWidth="1.2" strokeLinecap="round"/>
<line x1="0" y1="6" x2="2" y2="6" stroke="#2d6a4f" strokeWidth="1.2" strokeLinecap="round"/>
<line x1="10" y1="6" x2="12" y2="6" stroke="#2d6a4f" strokeWidth="1.2" strokeLinecap="round"/>
<line x1="1.8" y1="1.8" x2="3.2" y2="3.2" stroke="#2d6a4f" strokeWidth="1.2" strokeLinecap="round"/>
<line x1="8.8" y1="8.8" x2="10.2" y2="10.2" stroke="#2d6a4f" strokeWidth="1.2" strokeLinecap="round"/>
<line x1="10.2" y1="1.8" x2="8.8" y2="3.2" stroke="#2d6a4f" strokeWidth="1.2" strokeLinecap="round"/>
<line x1="3.2" y1="8.8" x2="1.8" y2="10.2" stroke="#2d6a4f" strokeWidth="1.2" strokeLinecap="round"/>
</svg>
)}
</div>
)}
{(cell.isAbsenz || cell.isPendingAbsenz) && !isSelected && !cell.isFerien && !cell.isPendingFerien && (
<div style={{ fontSize: 7, color: cell.isPendingAbsenz ? "#7a5a9a" : "#888", marginTop: 1 }}>
{cell.isPendingAbsenz ? "?" : <span className="material-icons" style={{fontSize:8,verticalAlign:"middle"}}>close</span>}
</div>
)}
{isVortag && !hasEntries && !cell.isFerien && !cell.isAbsenz && (
<div style={{ fontSize: 7, color: "#bbb", marginTop: 1 }}>1h</div>
)}
</button>
);
})}
</div>
<div style={{ marginTop: 16, paddingTop: 14, borderTop: "1px solid #ece8e2", display: "flex", justifyContent: "space-between", fontSize: 11 }}>
<span style={{ color: "#888" }}>Monatstotal</span>
<strong>{formatHours(monthTotalMins)}</strong>
</div>
</div>
{selectedEmp && (
<div className="card" style={{ marginTop: 12, padding: "14px 16px" }}>
<div style={{ paddingBottom: 8, marginBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div className="section-label" style={{ marginBottom: 4 }}>Saldo seit Eintritt</div>
{totalSaldo && [
{ label: "Soll bis heute", value: `${totalSaldo.soll}h` },
{ label: "IST", value: `${totalSaldo.ist}h` },
].map(r => (
<div key={r.label} style={{ display: "flex", justifyContent: "space-between", padding: "1px 0", fontSize: 11, color: "#555" }}>
<span>{r.label}</span><span>{r.value}</span>
</div>
))}
{totalSaldo && (
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, paddingTop: 4, borderTop: "1px solid #e0dbd4", fontFamily: "'Playfair Display', serif", fontSize: 13 }}>
<span>Saldo</span>
<strong style={{ color: totalSaldo.saldo >= 0 ? "#2d6a4f" : "#8a1a1a" }}>
{totalSaldo.saldo === 0 ? "0h" : `${totalSaldo.saldo > 0 ? "+" : ""}${totalSaldo.saldo}h`}
</strong>
</div>
)}
</div>
<div style={{ paddingBottom: 8, marginBottom: 8, borderBottom: "1px solid #ece8e2" }}>
<div className="section-label" style={{ marginBottom: 4 }}>Monatssaldo {new Date(currentDate + "T12:00:00").toLocaleString("de-CH", { month: "long" })}</div>
{monthSaldo && [
{ label: "Soll", value: `${monthSaldo.soll}h` },
{ label: "IST", value: `${monthSaldo.ist}h` },
].map(r => (
<div key={r.label} style={{ display: "flex", justifyContent: "space-between", padding: "1px 0", fontSize: 11, color: "#555" }}>
<span>{r.label}</span><span>{r.value}</span>
</div>
))}
{monthSaldo && (
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, paddingTop: 4, borderTop: "1px solid #e0dbd4", fontFamily: "'Playfair Display', serif", fontSize: 13 }}>
<span>Saldo</span>
<strong style={{ color: monthSaldo.saldo >= 0 ? "#2d6a4f" : "#8a1a1a" }}>
{monthSaldo.saldo === 0 ? "0h" : `${monthSaldo.saldo > 0 ? "+" : ""}${monthSaldo.saldo}h`}
</strong>
</div>
)}
</div>
<div>
<div className="section-label" style={{ marginBottom: 4 }}>Ferien {new Date().getFullYear()}</div>
{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 => (
<div key={r.label} style={{ display: "flex", justifyContent: "space-between", padding: "1px 0", fontSize: 11, color: "#555" }}>
<span>{r.label}</span><span>{r.value}</span>
</div>
))}
{ferienSaldo && (
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, paddingTop: 4, borderTop: "1px solid #e0dbd4", fontFamily: "'Playfair Display', serif", fontSize: 13 }}>
<span>Rest</span>
<strong style={{ color: ferienSaldo.rest >= 0 ? "#2d6a4f" : "#8a1a1a" }}>
{ferienSaldo.rest === 0 ? "0h" : `${ferienSaldo.rest > 0 ? "+" : ""}${ferienSaldo.rest}h`}
</strong>
</div>
)}
</div>
</div>
)}
</div>
</div>
<ReceiptViewer expense={receiptView} onClose={() => setReceiptView(null)} />
{contextMenu && (
<>
<div style={{ position: "fixed", inset: 0, zIndex: 999 }} onClick={() => setContextMenu(null)} onContextMenu={ev => { ev.preventDefault(); setContextMenu(null); }} />
<div style={{ position: "fixed", top: contextMenu.y, left: contextMenu.x, zIndex: 1000, background: "#fff", border: "1px solid #e0dbd4", borderRadius: 6, boxShadow: "0 4px 16px rgba(0,0,0,0.12)", minWidth: 180, padding: "4px 0" }}>
<button style={{ display: "block", width: "100%", textAlign: "left", padding: "8px 14px", fontSize: 12, background: "none", border: "none", cursor: "pointer", fontFamily: "inherit", color: "#1a1a18" }}
onMouseEnter={ev => ev.currentTarget.style.background = "#faf8f5"}
onMouseLeave={ev => ev.currentTarget.style.background = "none"}
onClick={() => { duplicateToNextDay(contextMenu.entry); setContextMenu(null); }}>
Auf morgen kopieren
</button>
</div>
</>
)}
</div>
);
}