/* ShiftBud — shared React kit: icons, store, components. Reads window.SB. */ /* eslint-disable */ const { useState, useEffect, useRef, useCallback } = React; const SBD = window.SB; const T = (k, p) => window.SBI.t(k, p); const DOW = (d) => window.SBI.dow(d); /* ---------------------------------------------------------------- Icons ---------------------------------------------------------------- */ const ICONS = { calendar: "M8 2v3M16 2v3M3.5 9.5h17M5 4.5h14a1.5 1.5 0 0 1 1.5 1.5V19A1.5 1.5 0 0 1 19 20.5H5A1.5 1.5 0 0 1 3.5 19V6A1.5 1.5 0 0 1 5 4.5Z", home: "M4 11.5 12 4l8 7.5M6 10v9.5h12V10", users: "M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19M10 10.5A3.25 3.25 0 1 0 10 4a3.25 3.25 0 0 0 0 6.5ZM20 19v-1.5a3.5 3.5 0 0 0-2.6-3.4M15.5 4.2a3.25 3.25 0 0 1 0 6.1", chat: "M20 11.5a7.5 7.5 0 0 1-10.9 6.7L4 19.5l1.3-4.1A7.5 7.5 0 1 1 20 11.5Z", check: "M4 12.5 9 17.5 20 6.5", plus: "M12 5v14M5 12h14", bell: "M18 8.5a6 6 0 1 0-12 0c0 7-2.5 8.5-2.5 8.5h17S18 15.5 18 8.5ZM10.5 21a2 2 0 0 0 3 0", swap: "M7 4 4 7l3 3M4 7h11a4 4 0 0 1 0 8M17 20l3-3-3-3M20 17H9a4 4 0 0 1 0-8", alert: "M12 4 2.5 20h19L12 4ZM12 10v4M12 17.5h.01", clock: "M12 7v5l3.5 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z", x: "M6 6l12 12M18 6 6 18", arrowR: "M5 12h14M13 6l6 6-6 6", search: "M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14ZM20 20l-4-4", settings: "M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM19.4 13a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-2.9 1.2V21a2 2 0 0 1-4 0v-.1A1.7 1.7 0 0 0 7 19.2l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1A1.7 1.7 0 0 0 5.4 13H5a2 2 0 0 1 0-4h.1A1.7 1.7 0 0 0 7 4.8L6.9 4.7a2 2 0 1 1 2.8-2.8l.1.1A1.7 1.7 0 0 0 12 3.1V3a2 2 0 0 1 4 0v.1a1.7 1.7 0 0 0 2.9 1.2l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0 .4 2.8h.4a2 2 0 0 1 0 4H21a1.7 1.7 0 0 0-1.6 1Z", phone: "M21 16.5v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 1 3.7 2 2 0 0 1 3 1.5h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.7a2 2 0 0 1-.5 2.1L7 9.3a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.7.7a2 2 0 0 1 1.7 2Z", leaf: "M11 20.5C6 20.5 3.5 16 3.5 11.5 3.5 6 8 3.5 20.5 3.5c0 12.5-2.5 17-9.5 17ZM4 20 13 11", grid: "M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z", trash: "M5 7h14M10 7V5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2M6.5 7l.8 12a1 1 0 0 0 1 .9h7.4a1 1 0 0 0 1-.9l.8-12", userplus: "M14 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-4A3.5 3.5 0 0 0 3 17.5V19M8.5 10.5a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM18 8v6M21 11h-6", moon: "M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5Z", sun: "M12 4V2M12 22v-2M6 6 4.5 4.5M19.5 19.5 18 18M4 12H2M22 12h-2M6 18l-1.5 1.5M19.5 4.5 18 6M12 16.5a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9Z", lock: "M7 11V8a5 5 0 0 1 10 0v3M6 11h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z", user: "M5 20v-1a5 5 0 0 1 5-5h4a5 5 0 0 1 5 5v1M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z", logout: "M15 17l5-5-5-5M20 12H9M9 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h3", chevR: "M9 6l6 6-6 6", help: "M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z", globe: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3.5 12h17M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18", sparkle: "M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8L12 3Z", billing: "M2 7.5A2.5 2.5 0 0 1 4.5 5h15A2.5 2.5 0 0 1 22 7.5v9a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 16.5v-9ZM2 10h20M6 14h4", }; function Icon({ name, style, className }) { return ( ); } /* ---------------------------------------------------------------- Store ---------------------------------------------------------------- */ function useShiftBud() { // ── Live data (from API) ─────────────────────────────────────────────────── const [shifts, setShifts] = useState([]); const [broadcastShiftIds, setBroadcastShiftIds] = useState(new Set()); const [tasks, setTasks] = useState([]); const [alerts, setAlerts] = useState([]); const [dismissedAlertIds, setDismissedAlertIds] = useState(new Set()); const [messages] = useState(() => SBD.MESSAGES.map((m) => ({ ...m }))); const [toasts, setToasts] = useState([]); const [weekOffset, setWeekOffset] = useState(0); const [categories, setCategories] = useState(() => SBD.CATEGORIES.map((c) => ({ ...c }))); const [unavail, setUnavail] = useState(() => SBD.UNAVAIL.map((u) => ({ ...u }))); const [weekdayHours, setWeekdayHours] = useState(() => { const o = {}; SBD.DOW_KEYS.forEach((d) => { o[d] = (SBD.WEEKDAY_HOURS[d] || []).map((b) => ({ ...b })); }); return o; }); const [staffV, setStaffV] = useState(0); const [billing, setBilling] = useState(null); const [loading, setLoading] = useState(false); const [theme, setThemeState] = useState(() => { try { return localStorage.getItem("sb_theme") || "light"; } catch (e) { return "light"; } }); const [mondaySchedule, setMondaySchedule] = useState(true); const [restaurantName, setRestaurantNameState] = useState(SBD.RESTAURANT.name); const [ownerName, setOwnerName] = useState(""); const [ownerEmail, setOwnerEmail] = useState(""); const setRestaurantName = useCallback((name) => { setRestaurantNameState(name); }, []); const [authed, setAuthed] = useState(false); const [authChecked, setAuthChecked] = useState(false); const unavailRef = useRef(unavail); useEffect(() => { unavailRef.current = unavail; }, [unavail]); const nextId = useRef(9000); // ── Auth ────────────────────────────────────────────────────────────────── // Check session on mount useEffect(() => { SBAPI.checkAuth().then(({ authed: a, owner }) => { setAuthed(a); setAuthChecked(true); if (owner) { if (owner.business_name) setRestaurantName(owner.business_name); if (owner.name) setOwnerName(owner.name); if (owner.email) setOwnerEmail(owner.email); } }); }, []); const login = useCallback(async (email, password) => { const result = await SBAPI.login(email, password); setAuthed(true); if (result) { if (result.business_name) setRestaurantName(result.business_name); if (result.name) setOwnerName(result.name); if (result.email) setOwnerEmail(result.email); } return result; }, []); const logout = useCallback(async () => { await SBAPI.logout(); setAuthed(false); setShifts([]); setTasks([]); // Keep SBD.STAFF intact as fallback — cleared on next loadStaff() }, []); // ── Load data when authenticated ────────────────────────────────────────── const loadStaff = useCallback(async () => { try { const list = await SBAPI.fetchStaff(); // Sync into SBD.STAFF so existing UI helpers (staffById etc.) still work SBD.STAFF.splice(0, SBD.STAFF.length, ...list); setStaffV(v => v + 1); } catch (e) { console.error("loadStaff", e); } }, []); const loadShifts = useCallback(async (offset) => { try { const list = await SBAPI.fetchShiftsForWeek(offset); setShifts(prev => { const other = prev.filter(s => s.week !== offset); return [...other, ...list]; }); } catch (e) { console.error("loadShifts", e); } }, []); // Alerts reaktiv aus Schichten ableiten useEffect(() => { const derived = shifts .filter(s => ["absent","declined","swap_requested"].includes(s.status)) .filter(s => !dismissedAlertIds.has(s.id)) .map(s => { const staff = SBD.staffById(s.staffId); const time = SBD.fmtRange(s.start, s.end); const day = SBD.WEEK[s.day] || { dow: "Mon", num: "" }; return { id: s.id, kind: s.status === "swap_requested" ? "swap" : "absence", staffId: s.staffId, shiftId: s.id, day: s.day, time, reason: s.notes || (s.status === "absent" ? "Krank gemeldet" : s.status === "declined" ? "Abgelehnt" : "Tauschanfrage"), at: `${SBD.WEEK[s.day]?.dow || ""} ${time}`, urgent: s.status === "absent", }; }); setAlerts(derived); }, [shifts, dismissedAlertIds]); const loadTasks = useCallback(async () => { try { const list = await SBAPI.fetchTasks(); setTasks(list); } catch (e) { console.error("loadTasks", e); } }, []); const loadOperatingHours = useCallback(async () => { try { const hours = await SBAPI.fetchOperatingHours(); setWeekdayHours(hours); // Kalender-Range aus echten Betriebszeiten ableiten const allBlocks = Object.values(hours).flat(); if (allBlocks.length > 0) { SBD.RESTAURANT.openHour = Math.min(...allBlocks.map(b => b.start)); SBD.RESTAURANT.closeHour = Math.max(...allBlocks.map(b => b.end)); } else { SBD.RESTAURANT.openHour = 0; SBD.RESTAURANT.closeHour = 24; } } catch (e) { console.error("loadOperatingHours", e); } }, []); const loadCategories = useCallback(async () => { try { const list = await SBAPI.fetchCategories(); if (list && list.length > 0) { setCategories(list); } else { // Erste Anmeldung: Demo-Kategorien einmalig in DB speichern const defaults = SBD.CATEGORIES.filter(c => c.label); const saved = await Promise.all(defaults.map(c => SBAPI.createCategory(c.label))); setCategories(saved); } } catch (e) { console.error("loadCategories", e); } }, []); const loadBilling = useCallback(async () => { try { const data = await fetch("/api/billing/status", { credentials: "include" }); if (data.ok) setBilling(await data.json()); } catch (e) { console.error("loadBilling", e); } }, []); const [holidays, setHolidays] = useState([]); const loadHolidays = useCallback(async () => { try { const list = await SBAPI.fetchHolidays(); setHolidays(list || []); } catch (e) { console.error("loadHolidays", e); } }, []); const saveHolidays = useCallback(async (list) => { try { const saved = await SBAPI.saveHolidays(list); setHolidays(saved); } catch (e) { console.error("saveHolidays", e); } }, []); const [generalMsgs, setGeneralMsgs] = useState([]); const loadGeneralMsgs = useCallback(async () => { try { const r = await fetch("/api/messages/general", { credentials: "include" }); if (r.ok) setGeneralMsgs(await r.json()); } catch (e) { console.error("loadGeneralMsgs", e); } }, []); const dismissGeneralMsg = useCallback(async (id) => { setGeneralMsgs((m) => m.filter((x) => x.id !== id)); await fetch(`/api/messages/general/${id}/read`, { method: "POST", credentials: "include" }); }, []); useEffect(() => { if (!authed) return; loadStaff(); loadShifts(0); loadTasks(); loadCategories(); loadOperatingHours(); loadBilling(); loadGeneralMsgs(); loadHolidays(); }, [authed, loadStaff, loadShifts, loadTasks, loadOperatingHours, loadBilling, loadGeneralMsgs]); // ── Week navigation: load from API on change ────────────────────────────── const gotoWeek = useCallback((offset) => { setWeekOffset(offset); loadShifts(offset); }, [loadShifts]); // ── Theme ────────────────────────────────────────────────────────────────── const isBlocked = useCallback((staffId, day, start, end) => { return unavailRef.current.find((u) => u.staffId === staffId && u.day === day && start < u.end && u.start < end) || null; }, []); const hasOverlap = useCallback((staffId, day, start, end, excludeId = null) => { return shifts.find((s) => s.staffId === staffId && s.day === day && s.week === weekOffset && s.status !== "cancelled" && s.id !== excludeId && start < s.end && s.start < end ) || null; }, [shifts, weekOffset]); useEffect(() => { document.documentElement.setAttribute("data-theme", theme); try { localStorage.setItem("sb_theme", theme); } catch (e) {} }, [theme]); const setTheme = useCallback((t) => setThemeState(t), []); const toggleTheme = useCallback(() => setThemeState((t) => (t === "dark" ? "light" : "dark")), []); useEffect(() => { window.__SB_CATS = categories; }, [categories]); const pushToast = useCallback((text, icon = "check") => { const id = Date.now() + Math.random(); setToasts((t) => [...t, { id, text, icon }]); setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2900); }, []); const confirmShift = useCallback(async (id) => { setShifts((arr) => arr.map((s) => s.id === id ? { ...s, status: "confirmed", draft: false } : s)); try { await SBAPI.patchShift(id, { status: "confirmed" }); pushToast(T("t_confirm")); } catch (e) { loadShifts(weekOffset); pushToast("Fehler beim Bestätigen", "alert"); } }, [pushToast, weekOffset, loadShifts]); const coverShift = useCallback(async (shiftId, newStaffId, alertId) => { // Broadcast-Modus zurücksetzen + Mitarbeiter zuweisen setShifts((arr) => arr.map((s) => { if (s.id !== shiftId) return s; return { ...s, staffId: newStaffId, status: "draft", draft: true, broadcastMode: false, broadcastAsked: 0 }; })); if (alertId != null) setAlerts((a) => a.filter((x) => x.id !== alertId)); const who = SBD.staffById(newStaffId) || _stub(newStaffId); try { await SBAPI.patchShift(shiftId, { staff_id: newStaffId, status: "draft", broadcast_mode: false }); pushToast(T("t_cover", { name: who.name.split(" ")[0] }), "swap"); } catch (e) { loadShifts(weekOffset); pushToast("Fehler beim Zuweisen", "alert"); } }, [pushToast, weekOffset, loadShifts]); const deleteWeek = useCallback(async (weekOffset) => { try { await SBAPI.deleteWeekShifts(weekOffset); setShifts((arr) => arr.filter((s) => s.week !== weekOffset)); pushToast("Alle Schichten der Woche gelöscht", "trash"); } catch (e) { pushToast("Fehler beim Löschen der Woche", "alert"); } }, [pushToast]); const deleteShiftSilent = useCallback(async (id, notify) => { let nm = ""; setShifts((arr) => { const s = arr.find((x) => x.id === id); if (s) { const st = SBD.staffById(s.staffId); nm = st ? st.name.split(" ")[0] : "Staff"; } return arr.filter((x) => x.id !== id); }); try { await SBAPI.deleteShift(id); } catch (_) {} if (notify) pushToast(T("t_deleted_notified", { name: nm }), "chat"); else pushToast(T("t_deleted"), "trash"); }, [pushToast]); const dismissAlert = useCallback((alertId, msg) => { setDismissedAlertIds(s => new Set([...s, alertId])); pushToast(msg || T("t_dismiss")); }, [pushToast]); const swapShifts = useCallback(async (reqShiftId, toShiftId, alertId) => { let aStaffId, bStaffId; setShifts((arr) => { const a = arr.find((s) => s.id === reqShiftId); const b = arr.find((s) => s.id === toShiftId); if (!a || !b) return arr; aStaffId = a.staffId; bStaffId = b.staffId; return arr.map((s) => { if (s.id === reqShiftId) return { ...s, staffId: bStaffId, status: "confirmed", notes: undefined }; if (s.id === toShiftId) return { ...s, staffId: aStaffId, status: "confirmed", notes: undefined }; return s; }); }); if (alertId != null) setAlerts((a) => a.filter((x) => x.id !== alertId)); try { await Promise.all([ SBAPI.patchShift(reqShiftId, { staff_id: bStaffId, status: "confirmed" }), SBAPI.patchShift(toShiftId, { staff_id: aStaffId, status: "confirmed" }), ]); pushToast(T("t_swapped"), "swap"); } catch (e) { loadShifts(weekOffset); pushToast("Fehler beim Tauschen", "alert"); } }, [pushToast, weekOffset, loadShifts]); const declineSwap = useCallback(async (alertId, staffId, shiftId) => { setShifts((arr) => arr.map((s) => s.id === shiftId ? { ...s, status: "confirmed", notes: undefined } : s)); setAlerts((a) => a.filter((x) => x.id !== alertId)); const who = SBD.staffById(staffId); try { await SBAPI.patchShift(shiftId, { status: "confirmed" }); pushToast(T("t_swap_declined", { name: who ? who.name.split(" ")[0] : "" })); } catch (e) { loadShifts(weekOffset); pushToast("Fehler", "alert"); } }, [pushToast, weekOffset, loadShifts]); const toggleTask = useCallback(async (id) => { setTasks(arr => arr.map(t => t.id === id ? { ...t, done: true } : t)); // optimistic try { await SBAPI.completeTask(id); } catch (e) { loadTasks(); // revert } }, [loadTasks]); const addShift = useCallback(async (data) => { const who = SBD.staffById(data.staffId); const nm = who ? who.name.split(" ")[0] : "staff"; try { const created = await SBAPI.createShift( data.week != null ? data.week : weekOffset, data.day, data.start, data.end, data.staffId, data.notes ); setShifts(arr => [...arr, created]); pushToast(T("t_added_draft", { name: nm }), "calendar"); } catch (e) { console.error("createShift error:", e.status, e.message, e); if (e.status === 409) pushToast("⚠️ Overlap: shift not created", "alert"); else pushToast(`Error ${e.status || "?"}: ${e.message || "shift not created"}`, "alert"); } }, [pushToast, weekOffset]); const deleteShift = useCallback(async (id) => { setShifts(arr => arr.filter(s => s.id !== id)); // optimistic try { await SBAPI.deleteShift(id); pushToast(T("t_deleted"), "trash"); } catch (e) { loadShifts(weekOffset); // revert on error pushToast("Error deleting shift", "alert"); } }, [pushToast, weekOffset, loadShifts]); const sendShift = useCallback(async (id) => { try { const updated = await SBAPI.publishShift(id); setShifts((arr) => arr.map((s) => s.id === id ? updated : s)); const who = SBD.staffById(updated.staffId); pushToast(T("t_sent_one", { name: who ? who.name.split(" ")[0] : "" }), "chat"); } catch (e) { if (e.status === 409) pushToast("Schicht wurde bereits gesendet", "alert"); else pushToast("Fehler beim Senden", "alert"); } }, [pushToast]); const sendAllDrafts = useCallback(async (week) => { try { const result = await SBAPI.publishWeek(week); await loadShifts(week); if (result.skipped_uninvited > 0) { pushToast(`${result.published} gesendet · ${result.skipped_uninvited} übersprungen (nicht eingeladen)`, "alert"); } else { pushToast(T("t_sent_all", { n: result.published }), "chat"); } } catch (e) { pushToast("Error publishing week", "alert"); } }, [pushToast, loadShifts]); const copyWeek = useCallback(async () => { try { // Always copy from the current week (sourceOffset=0) into the displayed week const created = await SBAPI.copyLastWeek(weekOffset, 0); setShifts((prev) => [...prev.filter((s) => s.week !== weekOffset), ...created]); pushToast(T("t_copied", { n: created.length }), "calendar"); } catch (e) { pushToast("Fehler beim Kopieren", "alert"); } }, [weekOffset, pushToast]); // gotoWeek defined above (uses loadShifts from API) const moveShift = useCallback(async (id, day, start) => { let endHour = null; setShifts((arr) => arr.map((s) => { if (s.id !== id) return s; const dur = s.end - s.start; endHour = start + dur; return { ...s, day, start, end: endHour }; })); if (endHour === null) return; try { await SBAPI.updateShift(id, weekOffset, day, start, endHour); } catch (e) { loadShifts(weekOffset); pushToast("Error moving shift", "alert"); } }, [weekOffset, loadShifts, pushToast]); const addStaff = useCallback(async (data) => { try { const created = await SBAPI.createStaff(data.name, data.phone || "", data.role || "staff"); SBD.STAFF.push(created); setStaffV(v => v + 1); pushToast(T("t_staffadd", { name: created.name.split(" ")[0] }), "users"); } catch (e) { if (e.status === 409) pushToast("Phone already registered", "alert"); else pushToast("Error adding staff", "alert"); } }, [pushToast]); const inviteStaff = useCallback(async (id) => { try { const updated = await SBAPI.inviteStaff(id); const idx = SBD.STAFF.findIndex(s => s.id === id); if (idx >= 0) SBD.STAFF[idx] = updated; setStaffV(v => v + 1); pushToast(`✅ Einladung an ${updated.name.split(" ")[0]} gesendet`, "chat"); return true; // ← Erfolg } catch (e) { pushToast("Fehler beim Einladen: " + (e.message || ""), "alert"); return false; // ← Fehler — bleibt uneingeladen } }, [pushToast]); const removeStaff = useCallback(async (id) => { const member = SBD.staffById(id); const name = member ? member.name.split(" ")[0] : "Staff"; try { await SBAPI.deactivateStaff(id); const idx = SBD.STAFF.findIndex(s => s.id === id); if (idx >= 0) SBD.STAFF.splice(idx, 1); setShifts(arr => arr.filter(s => s.staffId !== id)); setTasks(arr => arr.filter(t => t.staffId !== id)); setAlerts(arr => arr.filter(a => a.staffId !== id)); setStaffV(v => v + 1); pushToast(T("t_staffdel", { name })); } catch (e) { pushToast("Error removing staff", "alert"); } }, [pushToast]); // categories — persisted to DB const addCategory = useCallback(async (label) => { try { const created = await SBAPI.createCategory(label); setCategories((arr) => [...arr, created]); } catch (e) { console.error("addCategory", e); } }, []); const removeCategory = useCallback(async (id) => { // Demo-Kategorien haben String-IDs (z.B. "kueche") — nur lokal entfernen if (!isNaN(Number(id))) { try { await SBAPI.deleteCategory(id); } catch (e) { console.error("removeCategory", e); return; } } setCategories((arr) => arr.filter((c) => c.id !== id)); setShifts((arr) => arr.map((s) => (s.category === id ? { ...s, category: null } : s))); }, []); const renameCategory = useCallback(async (id, label) => { try { await SBAPI.renameCategory(id, label); setCategories((arr) => arr.map((c) => (c.id === id ? { ...c, label } : c))); } catch (e) { console.error("renameCategory", e); } }, []); // weekday hours editing — each change is persisted immediately const _saveHours = useCallback((newHours) => { SBAPI.saveOperatingHours(newHours).catch((e) => console.error("saveOperatingHours", e)); }, []); const setDayBlock = useCallback((dow, idx, patch) => { setWeekdayHours((h) => { const next = { ...h, [dow]: h[dow].map((b, i) => (i === idx ? { ...b, ...patch } : b)) }; _saveHours(next); return next; }); }, [_saveHours]); const addDayBlock = useCallback((dow) => { setWeekdayHours((h) => { const next = { ...h, [dow]: [...h[dow], { start: 12, end: 18 }] }; _saveHours(next); return next; }); }, [_saveHours]); const removeDayBlock = useCallback((dow, idx) => { setWeekdayHours((h) => { const next = { ...h, [dow]: h[dow].filter((_, i) => i !== idx) }; _saveHours(next); return next; }); }, [_saveHours]); const addUnavail = useCallback((data) => { setUnavail((arr) => [...arr, { id: Date.now(), ...data }]); }, []); const removeUnavail = useCallback((id) => { setUnavail((arr) => arr.filter((u) => u.id !== id)); }, []); return { shifts, tasks, alerts, messages, toasts, staffV, weekOffset, categories, weekdayHours, pushToast, holidays, loadHolidays, saveHolidays, inviteStaff, theme, setTheme, toggleTheme, mondaySchedule, setMondaySchedule, restaurantName, setRestaurantName, ownerName, ownerEmail, authed, authChecked, loading, login, logout, confirmShift, coverShift, dismissAlert, toggleTask, addShift, moveShift, addStaff, removeStaff, deleteShift, deleteShiftSilent, deleteWeek, sendShift, sendAllDrafts, copyWeek, gotoWeek, swapShifts, declineSwap, isBlocked, hasOverlap, addCategory, removeCategory, renameCategory, setDayBlock, addDayBlock, removeDayBlock, unavail, addUnavail, removeUnavail, billing, loadBilling, generalMsgs, dismissGeneralMsg, loadGeneralMsgs, loadShifts, broadcastShiftIds, setBroadcastShiftIds }; } /* ---------------------------------------------------------------- Primitives ---------------------------------------------------------------- */ function Avatar({ staff, size = 36, dot = false, mono = false }) { if (!staff) return null; return ( {SBD.initials(staff.name)} {dot && } ); } function AvatarStack({ ids, max = 5, size = 30, mono = false }) { const shown = ids.slice(0, max); const extra = ids.length - shown.length; return (
{shown.map((id) => )} {extra > 0 && ( +{extra} )}
); } const STATUS_LABEL = { asking: "st_asking", pending: "st_pending", confirmed: "st_confirmed", swap_requested: "st_swap", cancelled: "st_absent", covered: "st_covered" }; function StatusBadge({ status }) { return {T(STATUS_LABEL[status]) || status}; } function CoverageBar({ pct }) { const cls = pct >= 85 ? "" : pct >= 65 ? "warn" : "bad"; return
; } function Stat({ value, label, sub, subColor }) { return (
{value}
{label}
{sub &&
{sub}
}
); } /* ---------------------------------------------------------------- Week timeline — lane-packed, optional drag, status or staff colors ---------------------------------------------------------------- */ const TONE_BG = { green: "var(--st-green)", orange: "var(--st-orange)", red: "var(--st-red)" }; const CONFLICT_STATUSES = new Set(["cancelled", "absent", "declined", "swap_requested"]); function shiftTone(s, all) { if (CONFLICT_STATUSES.has(s.status)) return "red"; if (s.status === "confirmed") return "green"; const clash = all.some((o) => o.id !== s.id && o.staffId === s.staffId && o.day === s.day && !CONFLICT_STATUSES.has(o.status) && s.start < o.end && o.start < s.end); if (clash) return "red"; if (s.draft || s.status === "pending" || s.status === "asking") return "orange"; return "green"; } function computeStack(dayShifts) { // full-width bars stacked by start (later on top); same-start ties peek sideways const sorted = [...dayShifts].sort((a, b) => a.start - b.start || a.end - b.end); const info = {}; const tie = {}; sorted.forEach((s, i) => { const tieIdx = tie[s.start] || 0; tie[s.start] = tieIdx + 1; const multi = sorted.some((o) => o.id !== s.id && s.start < o.end && o.start < s.end); info[s.id] = { z: 4 + i, tieIdx, multi }; }); return info; } const TIE_PEEK = 24; // px nudge for shifts that start at the exact same time function WeekTimeline({ shifts, onShiftClick, draggable = false, onMove, height = 380, showAxis = true, stack = false, colorMode = "staff", nowHour = null }) { const open = SBD.RESTAURANT.openHour, close = SBD.RESTAURANT.closeHour, span = close - open; const tracksRef = useRef(null); const [drag, setDrag] = useState(null); // {id, day, start, dur} const [focus, setFocus] = useState(null); // shiftId brought to front const hourMarks = []; for (let h = open; h <= close; h += 2) hourMarks.push(h); const toY = (h) => ((h - open) / span) * height; const focusShift = focus != null ? shifts.find((s) => s.id === focus) : null; const beginDrag = (e, s) => { if (!draggable) return; e.preventDefault(); setDrag({ id: s.id, day: s.day, start: s.start, dur: s.end - s.start }); }; useEffect(() => { if (!drag) return; const onMoveEvt = (e) => { const rect = tracksRef.current.getBoundingClientRect(); const colW = rect.width / 7; let day = Math.floor((e.clientX - rect.left) / colW); day = Math.max(0, Math.min(6, day)); let start = open + ((e.clientY - rect.top) / rect.height) * span; start = Math.round(start * 2) / 2; // snap 30 min start = Math.max(open, Math.min(close - drag.dur, start)); setDrag((d) => ({ ...d, day, start })); }; const onUp = () => { setDrag((d) => { if (d && onMove) onMove(d.id, d.day, d.start); return null; }); }; window.addEventListener("pointermove", onMoveEvt); window.addEventListener("pointerup", onUp); return () => { window.removeEventListener("pointermove", onMoveEvt); window.removeEventListener("pointerup", onUp); }; }, [drag, onMove, open, close, span]); const renderBar = (s, opts = {}) => { const { ghost = false, z = 4, tieIdx = 0, multi = false } = opts; const staff = SBD.staffById(s.staffId) || { id: s.staffId, name: "?", role: "", color: "#aaa" }; const top = toY(s.start), h = toY(s.end) - toY(s.start); const pending = s.status === "pending"; const cancelled = CONFLICT_STATUSES.has(s.status); const swap = s.status === "swap_requested"; const statusMode = colorMode === "status"; const tone = statusMode ? shiftTone(s, shifts) : null; const focused = focus === s.id; const dim = !ghost && focusShift && !focused && focusShift.day === s.day && s.start < focusShift.end && focusShift.start < s.end; const style = { top: top, height: Math.max(h, 22), background: statusMode ? TONE_BG[tone] : (cancelled ? "#cfccc0" : staff.color), opacity: dim ? 0.34 : ((!statusMode && pending) ? 0.62 : 1), borderStyle: (!statusMode && pending) ? "dashed" : "solid", borderColor: "rgba(0,0,0,.06)", }; // full-width bars; same-start ties nudge sideways; focused jumps to front if (stack && multi && !ghost) { style.left = (5 + (focused ? 0 : tieIdx * TIE_PEEK)) + "px"; style.right = "5px"; style.zIndex = focused ? 30 : z; } if (focused && !ghost) { style.outline = "2.5px solid #fff"; style.boxShadow = "var(--shadow)"; } if (ghost) { Object.assign(style, { left: 4, right: 4, pointerEvents: "none", zIndex: 60, boxShadow: "var(--shadow-lg)", outline: "2px solid rgba(255,255,255,.75)" }); } const showName = focused || !multi; const onClick = () => { if (drag) return; if (stack && multi && !focused) setFocus(s.id); else onShiftClick && onShiftClick(s); }; return (
beginDrag(e, s)} onClick={onClick}> {swap && } {(cancelled || (tone === "red" && !swap)) && !}
{showName ? staff.name.split(" ")[0] : SBD.initials(staff.name)}
{showName && h > 34 &&
{SBD.fmtRange(s.start, s.end)}
}
); }; return (
{showAxis && (
{hourMarks.map((h) => ( {SBD.fmtHour(h)} ))}
)} {SBD.WEEK.map((d) => { const dayShifts = shifts.filter((s) => s.day === d.index); const st = stack ? computeStack(dayShifts) : null; return (
{d.dow}
{d.num}{d.isToday && }
{ if (e.target === e.currentTarget || e.target.classList.contains("gl") || e.target.classList.contains("tl-gridlines")) setFocus(null); }}>
{hourMarks.map((h) =>
)}
{nowHour != null && d.isToday && (
)} {dayShifts.map((s) => (drag && drag.id === s.id) ? null : renderBar(s, st ? st[s.id] : {}))} {drag && drag.day === d.index && (() => { const s = shifts.find((x) => x.id === drag.id); return renderBar({ ...s, start: drag.start, end: drag.start + drag.dur }, { ghost: true }); })()}
); })}
); } /* ---------------------------------------------------------------- DayRoster — vertical day stack; one row per person; horizontal time bar ---------------------------------------------------------------- */ function DayRoster({ shifts, weekDays, weekdayHours, categories, onShiftClick, onSend, onConfirm, onDelete, draggable = false, onMove, colorMode = "status", nowHour = null, todayIndex = null, broadcastIds = new Set(), customHolidays = [] }) { const open = SBD.RESTAURANT.openHour, close = SBD.RESTAURANT.closeHour, span = close - open; const hourMarks = []; for (let h = open; h <= close; h += 2) hourMarks.push(h); const toX = (h) => ((h - open) / span) * 100; const pd = useRef(null); const [drag, setDrag] = useState(null); // {id, start} const days = weekDays || SBD.WEEK; const catLabel = (id) => { const c = (categories || []).find((x) => x.id === id); return c ? c.label : null; }; useEffect(() => { if (!draggable) return; const move = (e) => { const p = pd.current; if (!p) return; if (!p.moved && Math.abs(e.clientX - p.x0) < 4) return; p.moved = true; const rect = p.track.getBoundingClientRect(); let start = open + ((e.clientX - rect.left) / rect.width) * span; start = Math.round(start * 2) / 2; start = Math.max(open, Math.min(close - p.dur, start)); p.curStart = start; setDrag({ id: p.id, start }); }; const up = () => { const p = pd.current; if (p && p.moved && onMove) onMove(p.id, p.day, p.curStart); pd.current = null; setDrag(null); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); return () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); }; }, [draggable, onMove, open, close, span]); // Hours within operating-hours blocks not covered by any non-cancelled shift const openingGap = (dayShifts, dayHours) => { let n = 0; for (const b of dayHours) { for (let h = b.start; h < b.end; h += 0.5) { if (!dayShifts.some(s => s.status !== "cancelled" && s.start <= h && s.end > h)) n += 0.5; } } return n; }; const peakConcurrent = (dayShifts) => { let mx = 0; for (let h = open; h < close; h++) { const n = dayShifts.filter((s) => s.status !== "cancelled" && s.start <= h && s.end > h).length; if (n > mx) mx = n; } return mx; }; const renderRow = (s, isToday) => { const staff = SBD.staffById(s.staffId) || { id: s.staffId, name: "?", role: "", color: "#aaa" }; const cancelled = CONFLICT_STATUSES.has(s.status); const swap = s.status === "swap_requested"; const statusMode = colorMode === "status"; const tone = statusMode ? shiftTone(s, shifts) : null; const isDraft = !!s.draft; // Broadcast-Ausschreibung: broadcast_mode aus API oder noch im client-seitigen Set const isBroadcast = (!!s.broadcastMode || broadcastIds.has(s.id)) && s.status === "asking"; const bg = isDraft ? "#b7b3a6" : isBroadcast ? "#c9850a" // sattes Amber-Gelb — klar von Orange/Grün unterscheidbar : (statusMode ? TONE_BG[tone] : (cancelled ? "#cfccc0" : staff.color)); const start = (drag && drag.id === s.id) ? drag.start : s.start; const end = start + (s.end - s.start); const left = toX(start), width = toX(end) - toX(start); const wide = width > 16; const cat = catLabel(s.category); return (
{/* Namenspalte: Broadcast zeigt 📢-Icon statt Mitarbeitername */} {isBroadcast ? (
📢
Vertretung
{s.broadcastAsked > 0 ? `${s.broadcastAsked} angefragt` : "gesucht"}
) : (
window.__SB_openMember && window.__SB_openMember(s.staffId)} title={staff.name}>
{staff.name.split(" ")[0]}
{cat || staff.role}
)}
{ if (el) el._shift = s; }}> {(weekdayHours && weekdayHours[days[s.day].dow] || []).map((b, i) => ( ))} {hourMarks.map((h) => )}
0 ? ` · ${s.broadcastAsked} angefragt` : ""}` : `${staff.name} · ${staff.role} · ${SBD.fmtRange(s.start, s.end)}${cat ? " · " + cat : ""}${isDraft ? " · " + T("draft") : ""}`} onPointerDown={(e) => { if (draggable) { const tr = e.currentTarget.parentElement; pd.current = { id: s.id, day: s.day, start: s.start, dur: s.end - s.start, track: tr, x0: e.clientX, moved: false, curStart: s.start }; } }} onClick={() => { if (pd.current && pd.current.moved) return; onShiftClick && onShiftClick(s); }}> {isBroadcast && 📢} {!isBroadcast && isDraft && } {!isBroadcast && swap && } {!isBroadcast && (cancelled || (tone === "red" && !swap)) && !isDraft && !} {SBD.fmtRange(s.start, s.end)} {!isBroadcast && wide && cat ? " · " + cat : ""} {!isBroadcast && wide && cancelled ? " · " + T("lab_absent") : ""} {!isBroadcast && wide && swap ? " · " + T("lab_swap") : ""}
{isDraft && onConfirm && } {isDraft && onSend && staff.whatsappInvited && } {isDraft && onSend && !staff.whatsappInvited && 📵} {onDelete && }
); }; return (
{hourMarks.map((h) => {SBD.fmtHour(h)})}
{days.map((d) => { const dayShifts = shifts.filter((s) => s.day === d.index).sort((a, b) => a.start - b.start || a.end - b.end); const dayHours = (weekdayHours && weekdayHours[d.dow]) || []; const isClosed = !dayHours.length; // Custom holidays (owner-configured) haben Vorrang vor hardcoded const customHol = customHolidays.find(h => h.month === (d.monthIdx + 1) && h.day === d.num); const holiday = customHol ? customHol.name : (SBD.germanHoliday ? SBD.germanHoliday(d.year, d.monthIdx, d.num) : null); const gapH = isClosed ? 0 : openingGap(dayShifts, dayHours); const isToday = todayIndex != null && todayIndex === d.index; return (
{DOW(d.dow)} {d.num} {d.monthIdx != null ? window.SBI.monthName(d.monthIdx) : window.SBI.month()} {isToday && {T("today")}} {holiday && {holiday}}
{T("working_peak", { n: dayShifts.filter(s=>s.status!=="cancelled").length, p: peakConcurrent(dayShifts) })} {!isClosed && 0 ? " warn" : " ok")}>{gapH === 0 ? "✓" : `${gapH}h offen`}}
{dayShifts.length ? dayShifts.map((s) => renderRow(s, isToday)) : isClosed ?
🔒{T("closed")}
{hourMarks.map((h) => )}
:
{T("no_shifts")}
{dayHours.map((b, i) => )} {hourMarks.map((h) => )}
}
); })}
); } /* ---------------------------------------------------------------- Alerts ---------------------------------------------------------------- */ const _stub = (id) => ({ id, name: "?", role: "", color: "#aaa" }); function SwapModal({ alert, shifts, onApprove, onDecline, onClose }) { const reqStaff = SBD.staffById(alert.staffId) || _stub(alert.staffId); const toStaff = SBD.staffById(alert.toStaffId) || _stub(alert.toStaffId); const reqShift = shifts.find((s) => s.id === alert.shiftId) || {}; const toShift = shifts.find((s) => s.id === alert.toShiftId) || {}; const catLabel = (s) => { const c = (window.__SB_CATS || []).find((x) => x.id === s.category); return c ? c.label : T("uncat"); }; const dayStr = (s) => s.day != null ? DOW(SBD.WEEK[s.day].dow) + " " + SBD.WEEK[s.day].num : ""; const timeStr = (s) => s.start != null ? SBD.fmtRange(s.start, s.end) : ""; const SideCard = ({ staff, shift, label, tone }) => (
{label}
{staff.name}
{staff.role}
{dayStr(shift)}
{timeStr(shift)}
{catLabel(shift)}
); return (
}>
{T("swap_arrow")}
“{alert.reason}” · {T("via_wa", { t: alert.at })}
{T("swap_after")}: {T("swap_after_d", { a: reqStaff.name.split(" ")[0], b: toStaff.name.split(" ")[0], aday: dayStr(reqShift), atime: timeStr(reqShift), acat: catLabel(reqShift), bday: dayStr(toShift), btime: timeStr(toShift), bcat: catLabel(toShift), })}
); } function AlertItem({ alert, shift, onCover, onDismiss, onSwap, onDeclineSwap, shifts, candidates, unavail }) { const staff = SBD.staffById(alert.staffId) || _stub(alert.staffId); const [open, setOpen] = useState(false); // Schicht-Zeiten für Verfügbarkeitscheck const shiftObj = shifts && shifts.find(s => s.id === alert.shiftId); const coverList = open && shiftObj ? staffCoverList(shifts, unavail || [], alert.day, shiftObj.start, shiftObj.end, alert.staffId) : null; const [swapOpen, setSwapOpen] = useState(false); const isAbsence = alert.kind === "absence"; const isSwap = alert.kind === "swap" && alert.toShiftId != null; const toStaff = isSwap ? (SBD.staffById(alert.toStaffId) || _stub(alert.toStaffId)) : null; return (
window.__SB_openMember && window.__SB_openMember(alert.staffId)} title={staff.name}>
{isAbsence ? T("al_absence") : T("al_swap")} {alert.urgent && {T("urgent")}}
{isSwap ? (
{staff.name} · {DOW(SBD.WEEK[alert.day].dow)} {SBD.WEEK[alert.day].num} · {alert.time}
{toStaff.name} · {DOW(SBD.WEEK[SBD.SHIFTS.find(s=>s.id===alert.toShiftId)?.day ?? alert.day]?.dow || "")} {SBD.SHIFTS.find(s=>s.id===alert.toShiftId)?.day != null ? SBD.WEEK[SBD.SHIFTS.find(s=>s.id===alert.toShiftId).day].num : ""}
) : (
{staff.name} · {DOW(SBD.WEEK[alert.day].dow)} {SBD.WEEK[alert.day].num} · {alert.time}
“{alert.reason}”
)}
{T("via_wa", { t: alert.at })}
{isSwap ? (
) : (!open && (
))} {open && !isSwap && (
{T("avail_cover")}
{(coverList || candidates).map((c) => ( ))}
)}
{swapOpen && setSwapOpen(false)} />}
); } /* ---------------------------------------------------------------- Tasks ---------------------------------------------------------------- */ function TaskRow({ task, onToggle }) { const staff = SBD.staffById(task.staffId) || _stub(task.staffId); return (
onToggle(task.id)}>
{task.title}
{staff.name.split(" ")[0]} · {task.dueLabel}{task.description ? " · " + task.description : ""}
); } /* ---------------------------------------------------------------- Message thread (WhatsApp inbox) ---------------------------------------------------------------- */ function MessageThread({ messages }) { return (
{messages.map((m) => { const staff = SBD.staffById(m.staffId) || _stub(m.staffId); return (
{m.dir === "in" && }
{m.dir === "in" && m.intent &&
{m.intent.replace("_", " ")}
} {m.body} {m.dir === "in" ? staff.name.split(" ")[0] + " · " : ""}{m.time}
); })}
); } /* ---------------------------------------------------------------- Modal + Add-shift form ---------------------------------------------------------------- */ function Modal({ title, children, foot, onClose }) { useEffect(() => { const h = (e) => e.key === "Escape" && onClose(); window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [onClose]); return (
e.stopPropagation()}>

{title}

{children} {foot &&
{foot}
}
); } function TimeRange({ start, end, onChange }) { const OPEN = SBD.RESTAURANT.openHour, CLOSE = SBD.RESTAURANT.closeHour, SPAN = CLOSE - OPEN; const trackRef = useRef(null); const drag = useRef(null); const snap = (h) => Math.round(h * 4) / 4; const clamp = (h) => Math.max(OPEN, Math.min(CLOSE, h)); const toPct = (h) => ((h - OPEN) / SPAN) * 100; const dur = end - start; const durLabel = (Number.isInteger(dur) ? dur : dur.toFixed(2).replace(/0+$/, "")) + " h"; const hAt = (clientX) => { const r = trackRef.current.getBoundingClientRect(); return snap(OPEN + ((clientX - r.left) / r.width) * SPAN); }; const begin = (which) => (e) => { e.preventDefault(); e.stopPropagation(); const startX = e.touches ? e.touches[0].clientX : e.clientX; const orig = { start, end }; drag.current = which; const move = (ev) => { const cx = ev.touches ? ev.touches[0].clientX : ev.clientX; if (which === "start") onChange(clamp(Math.min(hAt(cx), orig.end - 0.25)), orig.end); else if (which === "end") onChange(orig.start, clamp(Math.max(hAt(cx), orig.start + 0.25))); else { const r = trackRef.current.getBoundingClientRect(); let delta = snap(((cx - startX) / r.width) * SPAN); delta = Math.max(OPEN - orig.start, Math.min(CLOSE - orig.end, delta)); onChange(orig.start + delta, orig.end + delta); } }; const up = () => { drag.current = null; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("touchmove", move); window.removeEventListener("touchend", up); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }; const nudge = (which, delta) => { if (which === "start") onChange(clamp(snap(Math.min(start + delta, end - 0.25))), end); else onChange(start, clamp(snap(Math.max(end + delta, start + 0.25)))); }; const ticks = []; for (let h = OPEN; h <= CLOSE; h += 2) ticks.push(h); return (
{T("sh_start")}
{SBD.fmtHour(start)}
{durLabel}
{T("sh_end")}
{SBD.fmtHour(end)}
{ticks.map((h) => )}
{durLabel}
); } function AddShiftModal({ onClose, onAdd, categories, weekDays, weekOffset, weekLabel, isBlocked, hasOverlap, unavail, weekdayHours }) { const cats = categories || SBD.CATEGORIES; const days = weekDays || SBD.WEEK; // Nur eingeladene Mitarbeiter können Schichten zugewiesen bekommen const invitedStaff = SBD.STAFF.filter(s => s.whatsappInvited); const [staffId, setStaffId] = useState(invitedStaff[0]?.id ?? SBD.STAFF[0]?.id); const [day, setDay] = useState(SBD.TODAY_INDEX); const [start, setStart] = useState(8); const [end, setEnd] = useState(16); const [category, setCategory] = useState(cats[0] ? cats[0].id : ""); const [notes, setNotes] = useState(""); const [sendNow, setSendNow] = useState(false); const [showUn, setShowUn] = useState(false); const blocked = isBlocked ? isBlocked(+staffId, +day, +start, +end) : null; const overlap = hasOverlap ? hasOverlap(+staffId, +day, +start, +end) : null; const myUn = (unavail || []).filter((u) => u.staffId === +staffId); const submit = () => { onAdd({ staffId: +staffId, day: +day, start: +start, end: +end, category: category || null, notes: notes || undefined, send: sendNow, week: weekOffset }); onClose(); }; return (
}>
{weekLabel &&
{weekLabel}
}
{(() => { const DOW_KEYS = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]; const hours = weekdayHours && weekdayHours[DOW_KEYS[+day]]; if (!hours) return null; if (hours.length === 0) return (
Geschlossen
); const fmt = (h) => String(Math.floor(h)).padStart(2,"0") + ":" + String(Math.round((h%1)*60)).padStart(2,"0"); return (
Öffnungszeiten: {hours.map((b, i) => {i > 0 ? " · " : ""}{fmt(b.start)}–{fmt(b.end)})}
); })()}
{ setStart(s); setEnd(e); }} />
{overlap && (
{T("sh_overlap", { name: (SBD.staffById(+staffId)||_stub(+staffId)).name.split(" ")[0], time: SBD.fmtRange(overlap.start, overlap.end) })}
)} {blocked && (
{T("sh_blocked", { name: (SBD.staffById(+staffId)||_stub(+staffId)).name.split(" ")[0], reason: blocked.reason })}
{showUn && (
{myUn.map((u) => (
{DOW(SBD.DOW_KEYS[u.day])} {SBD.fmtRange(u.start, u.end)} {u.reason}
))}
)}
)}
setNotes(e.target.value)} placeholder={T("sh_notes_ph")} />
); } /* ---------------------------------------------------------------- Toasts ---------------------------------------------------------------- */ function Toasts({ toasts }) { return (
{toasts.map((t) => (
{t.text}
))}
); } /* ---------------------------------------------------------------- Shared derived helpers ---------------------------------------------------------------- */ function todayShifts(shifts) { return shifts.filter((s) => s.day === SBD.TODAY_INDEX && s.status !== "cancelled") .sort((a, b) => a.start - b.start); } /** Returns {label, state} for the weekly coverage chip in the Topbar. * Checks all open days this week — not just today. * state: "ok" | "gaps" | "none" | "closed" */ function coverageStatus(shifts, weekdayHours) { const DOW_KEYS = ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]; const weekShifts = shifts.filter((s) => s.week === 0 && s.status !== "cancelled"); if (!weekdayHours) return { label: window.SBI.t("cov_none"), state: "none" }; let openDays = 0, coveredDays = 0; for (let i = 0; i < 7; i++) { const hours = weekdayHours[DOW_KEYS[i]] || []; if (!hours.length) continue; // geschlossen openDays++; const dayShifts = weekShifts.filter((s) => s.day === i); if (dayShifts.length > 0) coveredDays++; } if (openDays === 0) return { label: window.SBI.t("cov_closed"), state: "closed" }; if (coveredDays === 0) return { label: window.SBI.t("cov_none"), state: "none" }; if (coveredDays === openDays) return { label: window.SBI.t("cov_ok"), state: "ok" }; const uncovered = openDays - coveredDays; return { label: window.SBI.t("cov_gaps", { n: uncovered }), state: "gaps" }; } function onNowCount(shifts, hour = 13) { return shifts.filter((s) => s.day === SBD.TODAY_INDEX && s.status !== "cancelled" && s.start <= hour && s.end > hour).length; } function coverCandidates(shifts, day, excludeId) { const busy = new Set(shifts.filter((s) => s.day === day && s.status !== "cancelled").map((s) => s.staffId)); return SBD.STAFF.filter((s) => s.id !== excludeId && !busy.has(s.id)).slice(0, 4); } /** * Gibt ALLE Mitarbeiter (außer excludeStaffId) zurück, je mit Verfügbarkeitsstatus: * "free" — keine Überschneidung, frei * "overlap" — hat eine Schicht die sich zeitlich überschneidet * "blocked" — hat eine Abwesenheitssperre in diesem Zeitraum * Sortierung: frei zuerst, dann overlap, dann blocked. */ function staffCoverList(shifts, unavail, day, start, end, excludeStaffId) { return SBD.STAFF .filter(s => s.id !== excludeStaffId) .map(s => { const overlapShift = shifts.find(o => o.staffId === s.id && o.day === day && o.status !== "cancelled" && start < o.end && o.start < end ); const blockedSlot = (unavail || []).find(u => u.staffId === s.id && u.day === day && start < u.end && u.start < end ); const avail = overlapShift ? "overlap" : blockedSlot ? "blocked" : "free"; const hint = overlapShift ? SBD.fmtRange(overlapShift.start, overlapShift.end) : blockedSlot ? (blockedSlot.reason || "Gesperrt") : null; return { ...s, avail, hint }; }) .sort((a, b) => ({ free: 0, overlap: 1, blocked: 2 }[a.avail] - { free: 0, overlap: 1, blocked: 2 }[b.avail])); } /** Kleines Verfügbarkeits-Badge für Mitarbeiterlisten */ function AvailBadge({ avail, hint }) { if (avail === "free") return ( ✓ Frei ); if (avail === "overlap") return ( ⚠ {hint} ); return ( 🔒 {hint} ); } /* ---------- Shared shift detail modal ---------- */ // Hilfskonstanten für Broadcast-Antwort-Anzeige const BROADCAST_RESP_ICON = { waiting: "⏳", accepted: "✅", declined: "❌", expired: "⚫" }; const BROADCAST_RESP_LABEL = { waiting: "Ausstehend", accepted: "Angenommen", declined: "Abgelehnt", expired: "Abgelaufen" }; const BROADCAST_RESP_COLOR = { waiting: "var(--muted)", accepted: "var(--st-green)", declined: "var(--st-red)", expired: "var(--faint)" }; function ShiftDetailModal({ shift, store, onClose }) { const staff = SBD.staffById(shift.staffId) || { id: shift.staffId, name: "?", role: "", color: "#aaa" }; const day = SBD.WEEK[shift.day]; // Alle Mitarbeiter mit Verfügbarkeitsstatus (zeitbasiert) const cands = staffCoverList(store.shifts, store.unavail, shift.day, shift.start, shift.end, shift.staffId); const needsCover = ["absent","declined"].includes(shift.status); const [coverMode, setCoverMode] = useState(needsCover); const [broadcasting, setBroadcasting] = useState(false); // Auswahl für selektiven Broadcast: null = kein Broadcast-Modus, Set = ausgewählte IDs const [broadcastSel, setBroadcastSel] = useState(null); // Broadcast-Status: wer wurde gefragt, was hat er geantwortet? const isBroadcast = !!shift.broadcastMode || (store.broadcastShiftIds && store.broadcastShiftIds.has(shift.id)); const [bcastStatus, setBcastStatus] = useState(null); // null=lädt, []=[Ergebnisse] const [bcastError, setBcastError] = useState(false); useEffect(() => { if (!isBroadcast) return; setBcastStatus(null); setBcastError(false); fetch(`/api/shifts/${shift.id}/broadcast-status`, { credentials: "include" }) .then(r => r.ok ? r.json() : Promise.reject(r.status)) .then(d => setBcastStatus(d)) .catch(() => setBcastError(true)); }, [shift.id, isBroadcast]); const toggleSel = (id) => setBroadcastSel(prev => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; }); const doBroadcast = async (staffIds) => { setBroadcasting(true); try { const body = staffIds ? { staff_ids: staffIds } : {}; const r = await fetch(`/api/shifts/${shift.id}/broadcast-cover`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const d = await r.json(); store.pushToast(`📢 ${d.asked} ${T("broadcast_cover_toast")}`, "bell"); store.setBroadcastShiftIds(prev => new Set([...prev, shift.id])); await store.loadShifts(store.weekOffset); onClose(); } finally { setBroadcasting(false); } }; return (
{staff.name}
{staff.role} · {staff.phone}
{T("sd_day")}
{DOW(day.dow)} {day.num} {window.SBI.month()}
{T("sd_time")}
{SBD.fmtRange(shift.start, shift.end)}
{T("sd_hours")}
{shift.end - shift.start}h
{(() => { const c = (store.categories || []).find((x) => x.id === shift.category); return c ?
{T("category")}{c.label}
: null; })()} {shift.draft &&
✎ {T("draft")} — {T("draft_note")}
} {shift.notes &&
📝 {shift.notes}
} {/* ── Broadcast-Status-Panel ───────────────────────────────────── */} {isBroadcast && (
📢 Broadcast — Vertretungsanfragen {shift.broadcastAsked > 0 && ( {shift.broadcastAsked} angefragt )}
{bcastError && (
Fehler beim Laden der Anfragen.
)} {!bcastError && bcastStatus === null && (
Lädt…
)} {!bcastError && bcastStatus !== null && bcastStatus.length === 0 && (
Noch keine Anfragen gesendet.
)} {!bcastError && bcastStatus !== null && bcastStatus.length > 0 && (
{bcastStatus.map(r => { const mem = SBD.staffById(r.staff_id) || { name: r.name, color: "#aaa", role: "" }; return (
{r.name} {BROADCAST_RESP_ICON[r.response]}{" "} {BROADCAST_RESP_LABEL[r.response]}
); })}
)}
)} {coverMode && (
{cands.length === 0 &&
Keine Mitarbeiter vorhanden
} {/* Direkte Zuweisung (kein Broadcast-Modus) */} {broadcastSel === null && ( <>
{T("pick_cover")}
{cands.map((c) => ( ))} {cands.length > 0 && (
)}
)} {/* Selektiver Broadcast-Modus */} {broadcastSel !== null && ( <>
{T("broadcast_select")}
{cands.map((c) => { const sel = broadcastSel.has(c.id); return ( ); })}
)}
)}
{shift.draft && store.sendShift && } {shift.status !== "confirmed" && !needsCover && } {!coverMode && }
); } /* ---------------------------------------------------------------- ResizeHandle — drag to resize an adjacent panel (width or height) ---------------------------------------------------------------- */ function useResizable(initial, { min = 220, max = 560, axis = "x", invert = false } = {}) { const [size, setSize] = useState(initial); const start = useRef(null); const onPointerDown = (e) => { e.preventDefault(); start.current = { pos: axis === "x" ? e.clientX : e.clientY, size }; const move = (ev) => { if (!start.current) return; const cur = axis === "x" ? ev.clientX : ev.clientY; let delta = cur - start.current.pos; if (invert) delta = -delta; setSize(Math.max(min, Math.min(max, start.current.size + delta))); }; const up = () => { start.current = null; window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); document.body.style.cursor = ""; }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); document.body.style.cursor = axis === "x" ? "col-resize" : "row-resize"; }; return [size, onPointerDown, setSize]; } function ResizeHandle({ onPointerDown, axis = "x" }) { return
; } Object.assign(window, { Icon, useShiftBud, Avatar, AvatarStack, StatusBadge, CoverageBar, Stat, WeekTimeline, DayRoster, AlertItem, TaskRow, MessageThread, Modal, AddShiftModal, Toasts, todayShifts, coverageStatus, onNowCount, coverCandidates, ShiftDetailModal, STATUS_LABEL, shiftTone, computeStack, TONE_BG, useResizable, ResizeHandle, });