/* 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); }}>
{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 (
>}>
“{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,
});