// Desktop Kalender — month grid w/ spanning multi-day bars + Plan (Belegungs- // plan: equipment × week timeline) + side-panel day timeline mirroring mobile. const mondayOfISO = (iso) => { const d = fromISO(iso); const dow = (d.getDay() + 6) % 7; // 0 = Mon d.setDate(d.getDate() - dow); return fmtDateISO(d); }; const shiftISO = (iso, days) => {const d = fromISO(iso);d.setDate(d.getDate() + days);return fmtDateISO(d);}; const parseTime = (s) => {if (!s || !/^\d{1,2}:\d{2}$/.test(s)) return null;const [h, m] = s.split(':').map(Number);return h + m / 60;}; const fmtHr = (hr) => {const h = Math.floor(hr),m = Math.round((hr - h) * 60);return String(h).padStart(2, '0') + ':' + (m === 60 ? '00' : String(m).padStart(2, '0'));}; // Tint any CSS color (#rgb, #rrggbb, rgb(), rgba()) to rgba with the given alpha. // Hex-suffix concat (`color + '1a'`) breaks if color isn't a 6-char hex — this is safe. function tintColor(c, a) { if (!c) return `rgba(120,120,128,${a})`; if (c[0] === '#') { let h = c.slice(1); if (h.length === 3) h = h.split('').map((x) => x + x).join(''); if (h.length === 6) { const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return `rgba(${r},${g},${b},${a})`; } } const m = c.match(/rgba?\(\s*(\d+)[ ,]+(\d+)[ ,]+(\d+)/i); if (m) return `rgba(${m[1]},${m[2]},${m[3]},${a})`; return c; // unknown format — return as-is } // Lane-pack a set of {start,end} intervals so they don't overlap in a row. function assignLanes(items) { const sorted = [...items].sort((a, b) => a.start.localeCompare(b.start) || a.end.localeCompare(b.end)); const laneEnds = []; // last 'end' of each lane (exclusive: a new item must start AFTER this) sorted.forEach((it) => { let lane = laneEnds.findIndex((end) => it.start > end); if (lane === -1) {lane = laneEnds.length;laneEnds.push(it.end);} else laneEnds[lane] = it.end; it.lane = lane; }); return { sorted, laneCount: Math.max(1, laneEnds.length) }; } function ScreenCalendar({ go, equipment, rentals, setRentals, events, setEvents }) { const t = useTheme(); const toast = useToast(); const today = todayISO(); const [mode, setMode] = useState('month'); // 'month' | 'plan' const [labelMode, setLabelMode] = useLocal('rf-d-cal-labelmode', 'name'); // 'name' | 'equipment' (month view bar text) const [cursor, setCursor] = useState(() => {const d = fromISO(today);return { y: d.getFullYear(), m: d.getMonth() };}); const [weekStart, setWeekStart] = useState(() => mondayOfISO(today)); const [selDay, setSelDay] = useState(today); const [evtSheet, setEvtSheet] = useState(null); const [peek, setPeek] = useState(null); // { kind, ref } const allBars = [ ...rentals.map((r) => { // Equipment label for the alternative month-view bar text const eq = equipment.find((e) => e.id === r.equipmentId); const items = Array.isArray(r.items) && r.items.length ? r.items : null; const eqName = items ? (items[0].equipmentName || eq && eq.name || r.equipmentName) + (items.length > 1 ? ' +' + (items.length - 1) : '') : eq && eq.name || r.equipmentName || ''; const eqTimes = (r.startTime ? '\u2191' + r.startTime : '') + (r.startTime && r.endTime ? ' ' : '') + (r.endTime ? '\u2193' + r.endTime : ''); return { kind: 'rental', id: r.id, title: r.tenantName, eqName, eqTimes, start: r.start, end: r.end, startTime: r.startTime, endTime: r.endTime, color: r.color || statusMeta(r.status).color, equipmentId: r.equipmentId, ref: r }; }), ...events.map((e) => ({ kind: 'event', id: e.id, title: e.title, eqName: e.title, eqTimes: '', start: e.start, end: e.end, startTime: e.startTime, endTime: e.endTime, color: e.color, equipmentId: e.equipmentId, ref: e }))]; const move = (d) => { if (mode === 'month') setCursor((c) => {let m = c.m + d,y = c.y;if (m < 0) {m = 11;y--;}if (m > 11) {m = 0;y++;}return { y, m };});else setWeekStart((w) => shiftISO(w, d * 7)); }; const goToday = () => { const d = fromISO(today);setCursor({ y: d.getFullYear(), m: d.getMonth() }); setWeekStart(mondayOfISO(today));setSelDay(today); }; const saveEvent = (ev) => {setEvents((prev) => {const ex = prev.find((x) => x.id === ev.id);return ex ? prev.map((x) => x.id === ev.id ? ev : x) : [...prev, ev];});toast('Termin gespeichert');}; const delEvent = (id) => {setEvents((prev) => prev.filter((x) => x.id !== id));toast('Termin gelöscht');}; const openPeek = (bar) => setPeek({ kind: bar.kind, ref: bar.ref }); const setEntryColor = (color) => { if (!peek) return; if (peek.kind === 'rental') { setRentals && setRentals((prev) => prev.map((x) => x.id === peek.ref.id ? { ...x, color } : x)); setPeek((p) => p && { ...p, ref: { ...p.ref, color } }); } else { setEvents((prev) => prev.map((x) => x.id === peek.ref.id ? { ...x, color } : x)); setPeek((p) => p && { ...p, ref: { ...p.ref, color } }); } }; const editFromPeek = () => { if (!peek) return; if (peek.kind === 'rental') {const id = peek.ref.id;setPeek(null);go('rentals', { rentalId: id });} else {const ev = peek.ref;setPeek(null);setEvtSheet(ev);} }; const subtitle = mode === 'month' ? `${DE_MONTHS[cursor.m]} ${cursor.y}` : `${fmtDateDE(weekStart)} – ${fmtDateDE(shiftISO(weekStart, 6))}`; return ( <>
move(-1)} title={mode === 'month' ? 'Vorheriger Monat' : 'Vorherige Woche'} /> move(1)} title={mode === 'month' ? 'Nächster Monat' : 'Nächste Woche'} />
{mode === 'month' ? : }
{/* day side panel — vertical timeline */} setEvtSheet({ id: '', title: '', equipmentId: '', start: selDay, end: selDay, color: '#5856D6', startTime: '09:00', endTime: '11:00', note: '' })} />
setPeek(null)} onColor={setEntryColor} onEdit={editFromPeek} onDelete={() => {if (peek && peek.kind === 'event') {delEvent(peek.ref.id);setPeek(null);}}} /> setEvtSheet(null)} onSave={saveEvent} onDelete={delEvent} /> ); } // ── Month view with spanning multi-day bars ── function MonthView({ t, cursor, bars, selDay, today, onPickDay, onPickBar, labelMode, setLabelMode }) { const firstDow = (new Date(cursor.y, cursor.m, 1).getDay() + 6) % 7; const daysInMonth = new Date(cursor.y, cursor.m + 1, 0).getDate(); const prevDays = new Date(cursor.y, cursor.m, 0).getDate(); // 6 week rows of 7 days const weeks = []; for (let w = 0; w < 6; w++) { const days = []; for (let i = 0; i < 7; i++) { const cellIdx = w * 7 + i; const dayNum = cellIdx - firstDow + 1; let date,inMonth = true; if (dayNum < 1) {date = new Date(cursor.y, cursor.m - 1, prevDays + dayNum);inMonth = false;} else if (dayNum > daysInMonth) {date = new Date(cursor.y, cursor.m + 1, dayNum - daysInMonth);inMonth = false;} else date = new Date(cursor.y, cursor.m, dayNum); days.push({ iso: fmtDateISO(date), date, inMonth }); } weeks.push(days); } const BAR_H = 19,BAR_GAP = 3,HEADER_H = 26; return (
{DE_DAYS_SHORT.slice(1).concat(DE_DAYS_SHORT[0]).map((d) =>
{d}
)}
{/* label-mode toggle (only shown in month view) */}
{weeks.map((week, wi) => { const weekStart = week[0].iso,weekEnd = week[6].iso; // Find bars intersecting this week, clipped to week bounds const segs = bars. filter((b) => b.end >= weekStart && b.start <= weekEnd). map((b) => ({ ...b, segStart: b.start < weekStart ? weekStart : b.start, segEnd: b.end > weekEnd ? weekEnd : b.end, startsHere: b.start >= weekStart, endsHere: b.end <= weekEnd })); // For lane packing, use segment bounds const { sorted, laneCount } = assignLanes(segs.map((s) => ({ ...s, start: s.segStart, end: s.segEnd }))); return (
{/* day cells (background + day number) */} {week.map((c) => { const isToday = c.iso === today; const isSel = c.iso === selDay; return ( onPickDay(c.iso)} scale={0.997}>
{c.date.getDate()}
); })} {/* spanning bars overlay */}
{(() => { const MAX_VIS = 3; // lanes 0..MAX_VIS-1 show bars; lane MAX_VIS reserved for "+N weitere" const visible = sorted.filter((s) => s.lane < MAX_VIS); const hidden = sorted.filter((s) => s.lane >= MAX_VIS); // For each day in the week, count how many hidden bars cover it const hiddenPerDay = week.map((c) => hidden.filter((s) => s.segStart <= c.iso && s.segEnd >= c.iso).length); return ( <> {visible.map((s) => { const startCol = (fromISO(s.segStart) - fromISO(weekStart)) / 86400000; const span = (fromISO(s.segEnd) - fromISO(s.segStart)) / 86400000 + 1; const topPx = s.lane * (BAR_H + BAR_GAP); return (
{e.stopPropagation && e.stopPropagation();onPickBar(s);}} scale={0.98}>
{(() => { const prefix = s.startsHere ? '' : '…'; if (labelMode === 'equipment') { const name = prefix + (s.eqName || s.title); return ( <> {name} {s.eqTimes && {' · ' + s.eqTimes}} ); } return {prefix + s.title}; })()}
); })} {/* per-day "+N weitere" pills */} {week.map((c, di) => { const n = hiddenPerDay[di]; if (!n) return null; return (
{ e.stopPropagation && e.stopPropagation(); onPickDay(c.iso); }} scale={0.96}>
+{n} weitere
); })} ); })()}
); })}
{setLabelMode &&
Beschriftung
}
); } // ── Plan view: equipment × week timeline (Belegungsplan) ── function PlanView({ t, weekStart, bars, equipment, today, selDay, onPickDay, onPickBar }) { const days = Array.from({ length: 7 }, (_, i) => { const iso = shiftISO(weekStart, i); return { iso, date: fromISO(iso) }; }); const COL_LABEL_W = 200; const BAR_H = 22,BAR_GAP = 4,ROW_PAD = 10,BASE_ROW_H = 50; const planByEq = {}; equipment.forEach((eq) => { const eqBars = bars.filter((b) => b.equipmentId === eq.id && b.end >= weekStart && b.start <= shiftISO(weekStart, 6)). map((b) => ({ ...b, segStart: b.start < weekStart ? weekStart : b.start, segEnd: b.end > shiftISO(weekStart, 6) ? shiftISO(weekStart, 6) : b.end, startsHere: b.start >= weekStart })); planByEq[eq.id] = assignLanes(eqBars.map((b) => ({ ...b, start: b.segStart, end: b.segEnd }))); }); const rowH = (eqId) => { const lc = (planByEq[eqId] || { laneCount: 1 }).laneCount; return Math.max(BASE_ROW_H, ROW_PAD * 2 + lc * BAR_H + (lc - 1) * BAR_GAP); }; return (
{/* day header */}
Equipment
{days.map((d) => { const isToday = d.iso === today,isSel = d.iso === selDay; return ( onPickDay(d.iso)} scale={0.98}>
{DE_DAYS_SHORT[d.date.getDay()]}
{d.date.getDate()}
); })}
{/* rows */} {equipment.map((eq) => { const { sorted } = planByEq[eq.id] || { sorted: [] }; const h = rowH(eq.id); return (
{eq.name}
{eq.cat}
{/* day column separators */} {days.map((d, i) =>
)} {/* bars */} {sorted.map((s) => { const startCol = (fromISO(s.segStart) - fromISO(weekStart)) / 86400000; const span = (fromISO(s.segEnd) - fromISO(s.segStart)) / 86400000 + 1; return ( onPickBar(s)} scale={0.98} style={{ position: 'absolute', top: ROW_PAD + s.lane * (BAR_H + BAR_GAP), height: BAR_H, left: `calc(${startCol / 7 * 100}% + 3px)`, width: `calc(${span / 7 * 100}% - 6px)` }}>
{(() => { const prefix = s.startsHere ? '' : '…'; const fromISOD = (iso) => { const d = fromISO(iso); return `${d.getDate()}.${d.getMonth() + 1}.`; }; const pickup = s.startTime ? `↑${fromISOD(s.start)} ${s.startTime}` : ''; const ret = s.endTime ? `↓${fromISOD(s.end)} ${s.endTime}` : ''; const meta = [pickup, ret].filter(Boolean).join(' '); return ( <> {prefix + s.title} {meta && {' · ' + meta}} ); })()}
); })}
); })}
); } // ── Day side panel: vertical hour timeline + multi-day strip ── // Mirrors the mobile day view: colored icon-circle nodes sit ON a central // strand at each event's start; pill cards extend to the right, height ∝ duration. function DaySidePanel({ t, dayISO, bars, equipment, today, onPickBar, onAdd }) { const dayBars = bars.filter((b) => dayISO >= b.start && dayISO <= b.end); const timed = [],multi = []; dayBars.forEach((b) => { const isFirst = b.start === dayISO,isLast = b.end === dayISO; const sHr = isFirst ? parseTime(b.startTime) : null; const eHr = isLast ? parseTime(b.endTime) : null; const sameDay = b.start === b.end; if (sameDay && sHr != null && eHr != null && eHr > sHr) timed.push({ b, sHr, eHr });else if (sameDay) multi.push({ b, isFirst: true, isLast: true, untimed: true });else multi.push({ b, isFirst, isLast }); }); timed.sort((a, c) => a.sHr - c.sHr || c.eHr - a.eHr); // Lane-pack timed events const tLaneEnds = []; timed.forEach((s) => { let lane = tLaneEnds.findIndex((end) => s.sHr >= end - 0.001); if (lane === -1) {lane = tLaneEnds.length;tLaneEnds.push(s.eHr);} else tLaneEnds[lane] = s.eHr; s.lane = lane; }); const LANE_COUNT = Math.max(1, tLaneEnds.length); const minHr = timed.length ? Math.max(0, Math.floor(Math.min(...timed.map((s) => s.sHr))) - 1) : 8; const maxHr = timed.length ? Math.min(24, Math.ceil(Math.max(...timed.map((s) => s.eHr))) + 1) : 18; const HOUR_H = 64; const TIMELINE_H = (maxHr - minHr) * HOUR_H + 32; const LINE_X = 78; // central strand x const NODE_R = 20; // node radius const PILL_LEFT = LINE_X + NODE_R + 4; const PILL_RIGHT_PAD = 14; const isToday = dayISO === today; const nowFrac = isToday ? new Date().getHours() + new Date().getMinutes() / 60 : -1; const day = fromISO(dayISO); const iconFor = (ev) => ev.kind === 'rental' ? icons.doc : icons.cal; return (
{DE_DAYS[day.getDay()]}{isToday ? ' · Heute' : ''}
{day.getDate()}. {DE_MONTHS[day.getMonth()]}
{dayBars.length} {dayBars.length === 1 ? 'Eintrag' : 'Einträge'} {timed.length > 0 && <>·{timed.length} mit Uhrzeit} {multi.length > 0 && <>·{multi.length} mehrtägig}
{/* ── Timed strand ── */} {(timed.length > 0 || dayBars.length === 0) &&
{/* Hour rail labels + dashed grid */} {Array.from({ length: maxHr - minHr + 1 }, (_, i) => { const h = minHr + i,top = i * HOUR_H + 16; return (
{String(h).padStart(2, '0')}:00
); })} {/* Central vertical strand */}
{/* Now-marker */} {nowFrac >= minHr && nowFrac <= maxHr &&
} {/* Event pills + nodes */} {timed.map((s) => { const ev = s.b; const eq = equipment.find((e) => e.id === ev.equipmentId); const top = (s.sHr - minHr) * HOUR_H + 16; const dur = s.eHr - s.sHr; const minutes = Math.round(dur * 60); const durLabel = minutes < 60 ? minutes + ' min' : dur % 1 === 0 ? dur + ' h' : dur.toFixed(1).replace('.', ',') + ' h'; const PILL_MIN_H = 60; const pillH = Math.max(dur * HOUR_H, PILL_MIN_H); const lanePct = 100 / LANE_COUNT; return (
{/* Node on the strand */}
{/* Pill, lane-split */} onPickBar(ev)} scale={0.99} style={{ position: 'absolute', top: 0, height: pillH, left: `calc(${PILL_LEFT}px + ${s.lane * lanePct}% - ${s.lane * (PILL_LEFT + PILL_RIGHT_PAD) / LANE_COUNT}px)`, width: `calc(${lanePct}% - ${(PILL_LEFT + PILL_RIGHT_PAD) / LANE_COUNT}px - 4px)` }}>
{fmtHr(s.sHr)}–{fmtHr(s.eHr)} · {durLabel} {ev.kind === 'rental' && MIETE}
{ev.title}
{eq &&
{eq.name}
}
); })} {timed.length === 0 &&
Keine Termine an diesem Tag.
}
} {/* ── Mehrtägig / Ganztägig strand ── */} {multi.length > 0 && (() => { const heading = multi.every((s) => s.untimed) ? 'Ganztägig' : multi.some((s) => s.untimed) ? 'Mehrtägig & ganztägig' : 'Mehrtägig'; return (
0 ? 8 : 0, padding: '14px 16px 0' }}>
{heading} · {multi.length} {multi.length === 1 ? 'Eintrag' : 'Einträge'}
{multi.map((s) => { const ev = s.b; const eq = equipment.find((e) => e.id === ev.equipmentId); const totalDays = daysBetween(ev.start, ev.end); const curIdx = daysBetween(ev.start, dayISO); return (
onPickBar(ev)} scale={0.99}>
{s.untimed ? 'Ganztägig' : `Tag ${curIdx + 1} / ${totalDays}`} {ev.kind === 'rental' && MIETE}
{ev.title}
{eq ? eq.name : ''}{s.untimed ? '' : (eq ? ' · ' : '') + fmtRange(ev.start, ev.end)}
); })}
); })()} {/* Add-entry CTA */}
Eintrag an diesem Tag hinzufügen
); } // ── Entry preview sheet — quick info + color picker + "Bearbeiten" ── function EntryPreviewSheet({ open, entry, equipment, onClose, onColor, onEdit, onDelete }) { const t = useTheme(); if (!entry) return null; const isRental = entry.kind === 'rental'; const ref = entry.ref; const eq = equipment.find((e) => e.id === ref.equipmentId); const days = daysBetween(ref.start, ref.end); const color = ref.color || (isRental ? statusMeta(ref.status).color : '#5856D6'); const sm = isRental ? statusMeta(ref.status) : null; const COLORS = ['#34C759', '#FF9500', '#5856D6', '#FF2D55', '#AF52DE', '#007AFF', '#1c1c1e', '#8E8E93']; const hasTime = ref.startTime || ref.endTime; // ── Rich rental view ── if (isRental) { const items = typeof getRentalItems === 'function' ? getRentalItems(ref, equipment) : ref.items || [{ equipmentName: ref.equipmentName, quantity: ref.quantity || 1, dailyRate: ref.dailyRate || 0 }]; const total = typeof rentalTotal === 'function' ? rentalTotal(ref) : 0; const fee = typeof rentalDeliveryFee === 'function' ? rentalDeliveryFee(ref) : 0; const paid = ref.paymentStatus === 'bezahlt'; const payDot = paid ? t.green : t.orange; const payLabel = paid ? 'Bezahlt' : 'Offen'; const dShort = (iso) => {const d = fromISO(iso);return `${d.getDate()}. ${DE_MONTHS[d.getMonth()]}`;}; const SectionLbl = ({ children, style }) =>
{children}
; return (
{/* Header */}
MIETVERTRAG
{sm &&
{sm.label}
}
{/* Customer name */}
{ref.tenantName}
{ref.purpose &&
{ref.purpose}
} {/* Zeitraum + Gesamtpreis */}
Zeitraum
{fmtRange(ref.start, ref.end)}
{days} Tag{days > 1 ? 'e' : ''}
Gesamtpreis
{total.toFixed(2).replace('.', ',')} €
{payLabel}
{/* Abholung + Rückgabe */} {hasTime &&
Abholung
{ref.startTime || '–'}{ref.startTime ? ' Uhr' : ''}
{dShort(ref.start)}
Rückgabe
{ref.endTime || '–'}{ref.endTime ? ' Uhr' : ''}
{dShort(ref.end)}
} {/* Mieter */}
Mieter
{ref.tenantName}
{ref.phone &&
{ref.phone}
} {ref.email &&
{ref.email}
} {ref.address &&
{ref.address}
}
{/* Equipment */}
Equipment · {items.length} {items.length === 1 ? 'Position' : 'Positionen'}
{items.map((it, i) => { const eqx = it.equipmentId && equipment.find((e) => e.id === it.equipmentId); const qty = Math.max(1, Number(it.quantity) || 1); const sum = days * (Number(it.dailyRate) || 0) * qty; return (
{eqx && eqx.name || it.equipmentName}
{qty} Stück · {Number(it.dailyRate || 0).toFixed(2).replace('.', ',')} €/Tag (Stück)
{sum.toFixed(2).replace('.', ',')} €
); })}
Summe · {days} Tag{days > 1 ? 'e' : ''}{fee > 0 ? ` · inkl. ${fee.toFixed(2).replace('.', ',')} € Lieferung` : ''}{Number(ref.discount) ? ` · −${Number(ref.discount)} € Rabatt` : ''}
{total.toFixed(2).replace('.', ',')} €
{/* Lieferung */} {ref.delivery && ref.delivery.enabled &&
Lieferung
{ref.delivery.address || ref.address}
{(Number(ref.delivery.km) > 0 || fee > 0) &&
{ref.delivery.km ? `${ref.delivery.km} km · ` : ''}{fee.toFixed(2).replace('.', ',')} € Liefergebühr
}
} {/* Farbe */}
Farbe
{COLORS.map((c) => onColor(c)} scale={0.86}>
)}
{/* Action */}
Im Vermietungen-Tab öffnen
); } // ── Event (non-rental) view — simpler ── return (
TERMIN
{ref.title}
Schließen
{/* meta rows */}
1 ? 'e' : ''}`} /> {hasTime && } {eq && } {ref.note && }
{/* color picker */}
Farbe
{COLORS.map((c) => onColor(c)} scale={0.86}>
)}
{/* actions */}
Löschen
Bearbeiten
); } function PeekRow({ t, icon, label, value, mono }) { return (
{label}
{value}
); } // ── Event dialog ── function EventSheet({ open, initial, equipment, onClose, onSave, onDelete }) { const t = useTheme(); const [form, setForm] = useState(initial || {}); useEffect(() => {if (open) setForm(initial || {});}, [open, initial]); const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); const isEdit = !!(initial && initial.id); const COLORS = ['#5856D6', '#FF2D55', '#AF52DE', '#34C759', '#FF9500', '#007AFF']; const valid = form.title && form.title.trim(); if (!open) return null; return (
{isEdit ? 'Termin bearbeiten' : 'Neuer Termin'}
Abbrechen
set('title', e.target.value)} placeholder="z.B. Werkstatt-Termin" />
set('start', e.target.value)} /> set('end', e.target.value)} />
{(() => { const allDay = !form.startTime && !form.endTime; const toggleAllDay = () => { if (allDay) setForm((f) => ({ ...f, startTime: '09:00', endTime: '11:00' })); else setForm((f) => ({ ...f, startTime: '', endTime: '' })); }; return ( <>
Ganzer Tag
{allDay ? 'Ohne feste Uhrzeit' : 'Mit Start- & Endzeit'}
{!allDay && (
set('startTime', e.target.value)} /> set('endTime', e.target.value)} />
)} ); })()} set('note', e.target.value)} placeholder="optional" />
{COLORS.map((c) => set('color', c)} scale={0.85}>
)}
{isEdit && {onDelete(form.id);onClose();}} scale={0.97}>
Löschen
} {if (!valid) return;onSave({ ...form, id: form.id || 'g-' + Date.now(), color: form.color || '#5856D6' });onClose();}} scale={0.98} style={{ flex: 1 }}>
{isEdit ? 'Speichern' : 'Anlegen'}
); } Object.assign(window, { ScreenCalendar, EventSheet });