// Calendar — clean month view with range pills (inspired by user's screenshot) // Plus add/edit/delete calendar events. const { useState, useEffect } = React; // ───────────────────────────────────────────────────────────── // Date math helpers (Mon-first weeks) // ───────────────────────────────────────────────────────────── const firstDayCol = (y, m) => { // m: 0-11. Returns 0..6 with Mon=0 const jsDay = new Date(y, m, 1).getDay(); // Sun=0..Sat=6 return (jsDay + 6) % 7; }; const daysInMonth = (y, m) => new Date(y, m + 1, 0).getDate(); // Inclusive day-difference using ISO strings const dayDiff = (aISO, bISO) => Math.round((fromISO(bISO) - fromISO(aISO)) / 86400000); // ───────────────────────────────────────────────────────────── // Mini date-range picker — clean style from screenshot // Single tap = single day. Second tap on later date = range end. Tapping on/before start resets. // ───────────────────────────────────────────────────────────── function MiniRangePicker({ start, end, onChange, anchorISO }) { const t = useTheme(); const anchorDate = anchorISO ? fromISO(anchorISO) : fromISO(start || todayISO()); const [view, setView] = useState({ y: anchorDate.getFullYear(), m: anchorDate.getMonth() }); const startOffset = firstDayCol(view.y, view.m); const dim = daysInMonth(view.y, view.m); const totalCells = Math.ceil((startOffset + dim) / 7) * 7; const days = ['MO', 'DI', 'MI', 'DO', 'FR', 'SA', 'SO']; const cellISO = (d) => `${view.y}-${String(view.m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; const isInRange = (d) => start && end && cellISO(d) >= start && cellISO(d) <= end; const isStart = (d) => start && cellISO(d) === start; const isEnd = (d) => end && cellISO(d) === end; const isToday = (d) => cellISO(d) === todayISO(); const onDayTap = (d) => { const iso = cellISO(d); if (!start || start && end && start !== end) { onChange(iso, iso);return; } if (iso < start) {onChange(iso, iso);return;} if (iso === start) {onChange(iso, iso);return;} onChange(start, iso); }; const move = (delta) => { const d = new Date(view.y, view.m + delta, 1); setView({ y: d.getFullYear(), m: d.getMonth() }); }; return (
{/* Header: prev / month name / next */}
move(-1)} scale={0.85}>
{DE_MONTHS[view.m]} {view.y}
move(1)} scale={0.85}>
{/* Day labels */}
{days.map((d) =>
{d}
)}
{/* Grid with range overlay */}
{Array.from({ length: totalCells }, (_, i) => { const d = i - startOffset + 1; const valid = d >= 1 && d <= dim; if (!valid) return
; const inR = isInRange(d),sS = isStart(d),sE = isEnd(d),today = isToday(d); const singleDay = start && end && start === end && sS; // Range pill background — connect cells horizontally const showLeftHalf = inR && !sS; const showRightHalf = inR && !sE; const isEdge = sS || sE; return ( onDayTap(d)} scale={0.92}>
{/* Connection bar (light gray) */} {inR && !singleDay &&
} {/* Endpoint dark fill */} {isEdge && !singleDay &&
} {/* Single-day filled */} {singleDay &&
} {/* Today outline (only if not the start) */} {today && !singleDay && !isEdge &&
} {d}
); })}
{/* Legend */}
Ausgewählt
Heute
); } // ───────────────────────────────────────────────────────────── // Month view — events shown as horizontal pills below day numbers. // Pill WIDTH within a single-day event reflects the time portion of the day // (e.g., 09:00–18:00 takes ~37% of the day cell width). All-day events get // a dashed, lighter pill so they stand out from timed ones. // Multi-day events span multiple day columns as before. // Pills are draggable horizontally to shift days; edges to extend duration. // ───────────────────────────────────────────────────────────── function CalMonthView({ view, setView, events, onPickDay, onPickEvent, equipment, onChange, toast, labelMode = 'name', setLabelMode }) { const t = useTheme(); // Long-press palette popover state const [palette, setPalette] = React.useState(null); // { ev, x, y } | null const longPressTimer = React.useRef(null); const longPressFired = React.useRef(false); const { y, m } = view; const startOffset = firstDayCol(y, m); const dim = daysInMonth(y, m); const totalCells = Math.ceil((startOffset + dim) / 7) * 7; const rows = totalCells / 7; const days = ['MO', 'DI', 'MI', 'DO', 'FR', 'SA', 'SO']; const MAX_LANES = 3; const cellISO = (d) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; const monthStart = cellISO(1); const monthEnd = cellISO(dim); const visibleEvents = events.filter((e) => !(e.end < monthStart || e.start > monthEnd)); // helpers const parseTime = (s) => { if (!s || typeof s !== 'string' || !/^\d{1,2}:\d{2}$/.test(s)) return null; const [h, mi] = s.split(':').map(Number); return h + mi / 60; }; // Lane-pack events into rows. Events beyond MAX_LANES are hidden and counted // as overflow per day-cell, then rendered as a single "+N" indicator. const segmentsByRow = Array.from({ length: rows }, () => []); const overflowByCell = Array.from({ length: rows }, () => Array.from({ length: 7 }, () => 0)); const sorted = [...visibleEvents].sort((a, b) => a.start.localeCompare(b.start) || daysBetween(b.start, b.end) - daysBetween(a.start, a.end)); // Track occupancy per lane without a cap so overflow ordering stays stable. const occ = Array.from({ length: rows }, () => Array.from({ length: 7 }, () => [])); for (const ev of sorted) { const evStart = ev.start < monthStart ? monthStart : ev.start; const evEnd = ev.end > monthEnd ? monthEnd : ev.end; const startIdx = startOffset + Number(evStart.slice(8)) - 1; const endIdx = startOffset + Number(evEnd.slice(8)) - 1; const startRow = Math.floor(startIdx / 7); const endRow = Math.floor(endIdx / 7); // Find the lowest lane (no cap) where the event fits across its full span. let lane = 0; while (true) { let free = true; for (let r = startRow; r <= endRow && free; r++) { const cs = r === startRow ? startIdx % 7 : 0; const ce = r === endRow ? endIdx % 7 : 6; for (let c = cs; c <= ce; c++) if (occ[r][c][lane]) { free = false; break; } } if (free) break; lane++; if (lane > 50) break; // safety guard } for (let r = startRow; r <= endRow; r++) { const colStart = r === startRow ? startIdx % 7 : 0; const colEnd = r === endRow ? endIdx % 7 : 6; for (let c = colStart; c <= colEnd; c++) occ[r][c][lane] = true; if (lane < MAX_LANES) { segmentsByRow[r].push({ ev, lane, colStart, colEnd, roundLeft: r === startRow, roundRight: r === endRow, showLabel: r === startRow, }); } else { for (let c = colStart; c <= colEnd; c++) overflowByCell[r][c]++; } } } const visibleLaneCount = segmentsByRow.map((segs) => Math.max(0, ...segs.map((s) => s.lane + 1))); const hasOverflow = overflowByCell.map((row) => row.some((n) => n > 0)); const NUM_H = 26, LANE_H = 18, ROW_PAD = 6, OVERFLOW_H = 14; const rowHeights = visibleLaneCount.map((n, r) => NUM_H + Math.max(1, n) * LANE_H + (hasOverflow[r] ? OVERFLOW_H + 2 : 0) + ROW_PAD); const today = todayISO(); const tint = (hex) => t.dark ? hex + '40' : hex + '24'; // Drag state — horizontal shift const [drag, setDrag] = React.useState(null); const containerRef = React.useRef(null); const shiftISO = (iso, days) => { const d = fromISO(iso); d.setDate(d.getDate() + days); return fmtDateISO(d); }; const startDrag = (e, ev, mode) => { if (!onChange) return; e.preventDefault(); e.stopPropagation(); try { e.currentTarget.setPointerCapture(e.pointerId); } catch {} longPressFired.current = false; if (longPressTimer.current) clearTimeout(longPressTimer.current); if (mode === 'move') { const rect = e.currentTarget.getBoundingClientRect(); longPressTimer.current = setTimeout(() => { longPressFired.current = true; setPalette({ ev, x: rect.left + rect.width / 2, y: rect.top }); setDrag(null); }, 550); } setDrag({ id: ev.id, mode, startX: e.clientX, dx: 0, origStart: ev.start, origEnd: ev.end, ev, moved: false }); }; const moveDrag = (e) => { if (!drag) return; const dx = e.clientX - drag.startX; if (Math.abs(dx) > 4 && longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } setDrag(d => d ? { ...d, dx, moved: d.moved || Math.abs(dx) > 4 } : null); }; const endDrag = (e, ev) => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } if (longPressFired.current) { longPressFired.current = false; return; } if (!drag) return; const w = containerRef.current ? containerRef.current.getBoundingClientRect().width / 7 : 50; const shift = Math.round((drag.dx || 0) / w); if (drag.moved && shift !== 0) { let ns = drag.origStart, ne = drag.origEnd; if (drag.mode === 'move') { ns = shiftISO(drag.origStart, shift); ne = shiftISO(drag.origEnd, shift); } if (drag.mode === 'resize-left') { ns = shiftISO(drag.origStart, shift); if (ns > ne) ns = ne; } if (drag.mode === 'resize-right') { ne = shiftISO(drag.origEnd, shift); if (ne < ns) ne = ns; } onChange({ ...ev, start: ns, end: ne }); if (toast) toast(`Verschoben auf ${fmtDateDE(ns)}${ns !== ne ? '–' + fmtDateDE(ne) : ''}`); } else if (!drag.moved) { onPickEvent(ev); } setDrag(null); }; return (
{days.map((d) =>
{d}
)}
{Array.from({ length: rows }, (_, r) => { const rowH = rowHeights[r]; return (
{/* day-number cells */}
{Array.from({ length: 7 }, (_, c) => { const i = r * 7 + c; const d = i - startOffset + 1; const valid = d >= 1 && d <= dim; const iso = valid ? cellISO(d) : null; const isT = iso === today; const weekend = c >= 5; return ( valid && onPickDay(iso)} scale={0.98} disabled={!valid} style={{ height: '100%' }}>
{valid ? d : ''}
); })}
{/* event pills */}
{segmentsByRow[r].map((seg, j) => { const ev = seg.ev; const isDragging = drag && drag.id === ev.id; const dx = isDragging ? drag.dx : 0; // Time-aware pill geometry — same transparent tinted style for all bars. // Single-day timed → narrower pill aligned to the time window. // Multi-day / all-day → full-width tinted pill across columns. const isMultiDay = ev.start !== ev.end; const sHr = parseTime(ev.startTime); const eHr = parseTime(ev.endTime); const hasTime = sHr !== null && eHr !== null && eHr > sHr; const singleDayTimed = !isMultiDay && hasTime; let leftPct = seg.colStart / 7 * 100; let widthPct = (seg.colEnd - seg.colStart + 1) / 7 * 100; if (singleDayTimed) { const dayLeft = seg.colStart / 7 * 100; const dayWidth = 1 / 7 * 100; leftPct = dayLeft + (sHr / 24) * dayWidth; widthPct = ((eHr - sHr) / 24) * dayWidth; } const pillBg = tint(ev.color); const pillTextColor = t.dark ? '#fff' : ev.color; const canDrag = !!onChange; return (
startDrag(e, ev, 'move') : undefined} onPointerMove={canDrag ? moveDrag : undefined} onPointerUp={canDrag ? (e) => endDrag(e, ev) : undefined} onPointerCancel={canDrag ? () => setDrag(null) : undefined} onClick={canDrag ? undefined : () => onPickEvent(ev)} style={{ position: 'absolute', top: seg.lane * LANE_H, height: LANE_H - 3, left: `calc(${leftPct}% + 2px)`, width: `calc(${widthPct}% - 4px)`, transform: isDragging ? `translateX(${dx}px)` : 'none', background: pillBg, borderTopLeftRadius: seg.roundLeft ? 5 : 0, borderBottomLeftRadius: seg.roundLeft ? 5 : 0, borderTopRightRadius: seg.roundRight ? 5 : 0, borderBottomRightRadius: seg.roundRight ? 5 : 0, display: 'flex', alignItems: 'center', padding: '0 4px', overflow: 'hidden', cursor: canDrag ? (isDragging ? 'grabbing' : 'grab') : 'pointer', touchAction: 'none', userSelect: 'none', zIndex: isDragging ? 12 : 3, }} title={ev.title}> {seg.showLabel && (() => { const detail = labelMode === 'detail' && ev.fromRental; if (!detail) { return ( {ev.title} ); } const eq = ev.equipmentLabel || ev.title; const hasTimes = ev.pickupTime || ev.returnTime; const times = hasTimes ? `↑${ev.pickupTime || '—'} ↓${ev.returnTime || '—'}` : ''; return ( {eq} {times && {' · ' + times}} ); })()} {canDrag && seg.roundLeft && (
startDrag(e, ev, 'resize-left')} style={{ position: 'absolute', top: 0, bottom: 0, left: 0, width: 6, cursor: 'ew-resize', zIndex: 3 }}/> )} {canDrag && seg.roundRight && (
startDrag(e, ev, 'resize-right')} style={{ position: 'absolute', top: 0, bottom: 0, right: 0, width: 6, cursor: 'ew-resize', zIndex: 3 }}/> )}
); })} {/* "+N weitere" overflow indicators per day cell */} {hasOverflow[r] && (() => { // Group consecutive cells with the same overflow count into one pill // so multi-day clusters read as one chip rather than 5 tiny boxes. const lane = visibleLaneCount[r]; const top = lane * LANE_H + 1; const cells = overflowByCell[r]; const pills = []; let c = 0; while (c < 7) { if (cells[c] > 0) { let end = c; while (end + 1 < 7 && cells[end + 1] === cells[c]) end++; pills.push({ c, end, n: cells[c] }); c = end + 1; } else c++; } return pills.map((p, k) => { const left = p.c / 7 * 100; const w = (p.end - p.c + 1) / 7 * 100; const d = r * 7 + p.c - startOffset + 1; const iso = d >= 1 && d <= dim ? cellISO(d) : null; return ( iso && onPickDay(iso)} scale={0.95}>
+{p.n} weitere
); }); })()}
); })}
{/* Label-Modus Umschalter */} {setLabelMode && (
Vollere Balken = Uhrzeit-Anteil · ziehen verschiebt den Tag
{[['name', 'Name'], ['detail', 'Gerät · Zeit']].map(([id, label]) => ( setLabelMode(id)} scale={0.95}>
{label}
))}
)} {/* Long-press color palette */} {palette && (
setPalette(null)} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.35)', zIndex: 999, display: 'flex', alignItems: 'center', justifyContent: 'center', }}>
e.stopPropagation()} style={{ background: t.card, borderRadius: 18, padding: '16px 18px', boxShadow: '0 16px 50px rgba(0,0,0,0.35)', maxWidth: 300, width: '88%', }}>
{palette.ev.title}
setPalette(null)} scale={0.85}>
Kalenderfarbe ändern
{EVENT_COLORS.map(c => ( { onChange({ ...palette.ev, color: c }); setPalette(null); toast && toast('Farbe geändert'); }} scale={0.88}>
))} {/* Custom color via native picker */}
)}
); } // ───────────────────────────────────────────────────────────── // Belegungsplan — equipment × time resource grid (timeline) // ───────────────────────────────────────────────────────────── function CalPlanView({ view, events, equipment, rentals = [], setRentals, onPickEvent, onPickDay, weekStartISO, onMoveWeek, toast }) { const t = useTheme(); // 7-day window beginning weekStartISO (Monday) const start = fromISO(weekStartISO); const dayList = Array.from({ length: 7 }, (_, i) => {const d = new Date(start);d.setDate(d.getDate() + i);return fmtDateISO(d);}); const winStart = dayList[0],winEnd = dayList[6]; const today = todayISO(); const LABEL_W = 116; // frozen left column const DAY_W = 96; // each day column const ROW_H = 56; const HEAD_H = 40; // Drag state: { id, kind, startX, dx, origStart, origEnd, didMove } const [drag, setDrag] = React.useState(null); const dragDayShift = drag ? Math.round(drag.dx / DAY_W) : 0; const shiftISO = (iso, days) => { const d = fromISO(iso); d.setDate(d.getDate() + days); return fmtDateISO(d); }; const startDrag = (e, b) => { if (b.kind !== 'rental' || !setRentals) return; if (b.ref && b.ref.status === 'abgeschlossen') return; e.preventDefault(); e.stopPropagation(); try { e.currentTarget.setPointerCapture(e.pointerId); } catch {} setDrag({ id: b.id, kind: b.kind, startX: e.clientX, dx: 0, origStart: b.start, origEnd: b.end, didMove: false }); }; const moveDrag = (e) => { if (!drag) return; const dx = e.clientX - drag.startX; setDrag(d => d ? { ...d, dx, didMove: d.didMove || Math.abs(dx) > 4 } : null); }; const endDrag = (e, b) => { if (!drag) return; const moved = drag.didMove; const shift = Math.round(drag.dx / DAY_W); if (moved && shift !== 0 && b.kind === 'rental') { const ns = shiftISO(drag.origStart, shift); const ne = shiftISO(drag.origEnd, shift); setRentals(prev => prev.map(r => r.id === drag.id ? { ...r, start: ns, end: ne } : r)); if (toast) toast(`Verschoben auf ${fmtDateDE(ns)}`); } else if (!moved && b.kind === 'rental' && onPickDay) { onPickDay(b.start); } setDrag(null); }; const cancelDrag = () => setDrag(null); // group equipment by category/kind const groups = {}; equipment.forEach((e) => {const k = e.kind || 'Sonstige';(groups[k] = groups[k] || []).push(e);}); const groupNames = Object.keys(groups); // Bookings = rentals (one per item) + events, normalized to { equipmentId, start, end, title, color } const bookings = [ ...rentals.filter((r) => r.status !== 'abgeschlossen').flatMap((r) => { const items = getRentalItems(r); return items.map((it) => ({ kind: 'rental', id: r.id, equipmentId: it.equipmentId, start: r.start, end: r.end, startTime: r.startTime, endTime: r.endTime, title: r.tenantName + (it.quantity > 1 ? ' ×' + it.quantity : ''), color: r.color || statusMeta(r.status).color, ref: r, })); }), ...events.map((e) => ({ kind: 'event', id: e.id, equipmentId: e.equipmentId, start: e.start, end: e.end, title: e.title, color: e.color, ref: e })), ].filter((b) => !(b.end < winStart || b.start > winEnd)); const colIndex = (iso) => dayList.indexOf(iso); const todayCol = colIndex(today); const weekLabel = `${fromISO(winStart).getDate()}.–${fromISO(winEnd).getDate()}. ${DE_MONTHS_SHORT[fromISO(winEnd).getMonth()]}`; // Bar geometry + greedy lane assignment so overlapping bookings stack instead of covering each other const BAR_H = 30, BAR_GAP = 4, ROW_PAD = 8, BASE_ROW_H = 56; const assignLanes = (bks) => { const sorted = [...bks].sort((a, b) => a.start.localeCompare(b.start) || a.end.localeCompare(b.end)); const laneEnds = []; // last end-ISO occupying each lane sorted.forEach((b) => { let lane = laneEnds.findIndex((end) => b.start > end); if (lane === -1) { lane = laneEnds.length; } laneEnds[lane] = b.end; b._lane = lane; }); return { sorted, laneCount: Math.max(1, laneEnds.length) }; }; // Precompute per-equipment lanes + row heights const planByEq = {}; equipment.forEach((eq) => { planByEq[eq.id] = assignLanes(bookings.filter((b) => b.equipmentId === eq.id)); }); const rowHeightFor = (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 (
{/* week nav */}
{weekLabel}
onMoveWeek(-1)} scale={0.88}>
onMoveWeek(1)} scale={0.88}>
{/* Scroll container */}
{/* Header: day columns */}
{dayList.map((iso, i) => { const dd = fromISO(iso); const isT = iso === today; return ( onPickDay(iso)} scale={0.97} style={{ width: DAY_W, flexShrink: 0 }}>
{['MO', 'DI', 'MI', 'DO', 'FR', 'SA', 'SO'][i].toUpperCase()}
{dd.getDate()}
); })}
{/* Today vertical line */} {todayCol >= 0 &&
} {/* Rows grouped by category */} {groupNames.map((gn) =>
{/* group header */}
{gn}
{groups[gn].map((eq) => { const { sorted: eqBookings } = planByEq[eq.id] || { sorted: [] }; const rowH = rowHeightFor(eq.id); return (
{/* day cells background grid (full row height) */}
{dayList.map((iso, i) =>
)}
{/* booking bars (absolute, lane-stacked) */} {eqBookings.map((b) => { const bs = b.start < winStart ? winStart : b.start; const be = b.end > winEnd ? winEnd : b.end; const cStart = colIndex(bs),cEnd = colIndex(be); if (cStart < 0 || cEnd < 0) return null; const clipLeft = b.start < winStart, clipRight = b.end > winEnd; const left = LABEL_W + cStart * DAY_W + 3; const width = (cEnd - cStart + 1) * DAY_W - 6; const top = ROW_PAD + b._lane * (BAR_H + BAR_GAP); const isDragging = drag && drag.id === b.id; const dragShift = isDragging ? drag.dx : 0; const isDraggable = b.kind === 'rental' && b.ref && b.ref.status !== 'abgeschlossen' && !!setRentals; return (
startDrag(e, b) : undefined} onPointerMove={isDraggable ? moveDrag : undefined} onPointerUp={isDraggable ? (e) => endDrag(e, b) : undefined} onPointerCancel={isDraggable ? cancelDrag : undefined} onClick={isDraggable ? undefined : () => (b.kind === 'event' ? onPickEvent(b.ref) : onPickDay(bs))} style={{ position: 'absolute', top, height: BAR_H, left, width, zIndex: isDragging ? 5 : 2, cursor: isDraggable ? (isDragging ? 'grabbing' : 'grab') : 'pointer', touchAction: 'none', userSelect: 'none', background: t.dark ? b.color + '40' : b.color + '24', borderTopLeftRadius: clipLeft ? 0 : 7, borderBottomLeftRadius: clipLeft ? 0 : 7, borderTopRightRadius: clipRight ? 0 : 7, borderBottomRightRadius: clipRight ? 0 : 7, padding: '0 8px', display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden', transform: isDragging ? `translateX(${dragShift}px)` : 'none', boxShadow: isDragging ? '0 8px 22px rgba(0,0,0,0.25)' : 'none', outline: isDragging ? `2px solid ${b.color}` : 'none', transition: isDragging ? 'none' : 'transform 0.15s, box-shadow 0.15s', opacity: isDragging ? 0.92 : 1, }}> {b.title} {b.kind === 'rental' && (b.startTime || b.endTime) && ( {b.startTime || '–'}–{b.endTime || '–'} )} {isDragging && dragDayShift !== 0 ? `${dragDayShift > 0 ? '+' : ''}${dragDayShift} Tag${Math.abs(dragDayShift) === 1 ? '' : 'e'}` : `${fromISO(b.start).getDate()}.–${fromISO(b.end).getDate()}.`}
); })} {/* frozen equipment label */}
{eq.name}
{eq.price} €/Tag
); })}
)}
{/* (legend removed — bar colors now follow the event/rental color directly) */}
↔ Pill ziehen zum Verschieben
); } // ───────────────────────────────────────────────────────────── // Day-detail bottom sheet // ───────────────────────────────────────────────────────────── function DaySheet({ open, dayISO, onClose, events, equipment, onAdd, onEdit, onDelete }) { const t = useTheme(); const dayEvents = events.filter((e) => dayISO && e.start <= dayISO && e.end >= dayISO && !e.synthetic); return (
{dayISO === todayISO() ? 'Heute · ' : ''}{dayISO && DE_DAYS[fromISO(dayISO).getDay()]}
{dayISO && `${fromISO(dayISO).getDate()}. ${DE_MONTHS[fromISO(dayISO).getMonth()]}`}
Schließen
{dayEvents.length === 0 &&
Keine Einträge an diesem Tag.
} {dayEvents.map((ev) => { const eq = equipment.find((e) => e.id === ev.equipmentId); return ( onEdit(ev)} onEdit={() => onEdit(ev)} onDelete={ev.fromRental ? null : () => onDelete(ev)}>
{ev.title} {ev.fromRental && MIETE}
{eq ? eq.name : 'Allgemein'} · {fmtRange(ev.start, ev.end)}
{ev.fromRental && (ev.startTime || ev.endTime || ev.pickupTime || ev.returnTime) && (() => { const pu = ev.pickupTime || ev.startTime; const rt = ev.returnTime || ev.endTime; const isStartDay = ev.start === dayISO; const isEndDay = ev.end === dayISO; const sd = fromISO(ev.start), ed = fromISO(ev.end); const sLbl = `${String(sd.getDate()).padStart(2,'0')}.${String(sd.getMonth()+1).padStart(2,'0')}.`; const eLbl = `${String(ed.getDate()).padStart(2,'0')}.${String(ed.getMonth()+1).padStart(2,'0')}.`; return (
↑ Abholung {sLbl} {pu ? pu + ' Uhr' : '–'} · ↓ Rückgabe {eLbl} {rt ? rt + ' Uhr' : '–'}
); })()} {ev.note &&
{ev.note}
}
); })}
onAdd(dayISO)} scale={0.97} style={{ marginTop: 16 }}>
+ Eintrag erstellen
); } // ───────────────────────────────────────────────────────────── // Add / edit event sheet // ───────────────────────────────────────────────────────────── function EventSheet({ open, onClose, initial, defaultDate, equipment, onSave, onDelete }) { const t = useTheme(); const isEdit = !!(initial && initial.id); const blank = { id: '', title: '', equipmentId: '', start: defaultDate || todayISO(), end: defaultDate || todayISO(), color: EVENT_COLORS[0], note: '' }; const [form, setForm] = useState(blank); const [showPicker, setShowPicker] = useState(false); useEffect(() => { if (open) { setForm(initial ? { ...blank, ...initial } : { ...blank, start: defaultDate || todayISO(), end: defaultDate || todayISO() }); setShowPicker(false); } }, [open, initial, defaultDate]); const set = (k, v) => setForm((f) => ({ ...f, [k]: v })); const valid = form.title.trim() && form.start && form.end; const save = () => { if (!valid) return; const out = { ...form, id: form.id || 'ev-' + Date.now() }; onSave(out); onClose(); }; const eq = equipment.find((e) => e.id === form.equipmentId); return (
{isEdit ? 'Eintrag bearbeiten' : 'Neuer Eintrag'}
Termin / Buchung im Kalender
Abbrechen
Titel
set('title', e.target.value)} placeholder="z.B. Hochzeit Müller" style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: `0.5px solid ${t.inputBorder}`, background: t.card, fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box' }} />
Equipment · optional
set('equipmentId', '')} scale={0.95}>
Allgemein
{equipment.map((e) => set('equipmentId', e.id)} scale={0.95}>
{e.name}
)}
Zeitraum
setShowPicker((s) => !s)} scale={0.99}>
{fmtRange(form.start, form.end)}
{daysBetween(form.start, form.end)} Tag{daysBetween(form.start, form.end) > 1 ? 'e' : ''}
{showPicker &&
{set('start', s);set('end', e);}} anchorISO={form.start} />
}
{/* Uhrzeit von / bis */}
Uhrzeit
Ganzer Tag
Ohne feste Uhrzeit
{(() => { const allDay = !form.startTime && !form.endTime; const toggle = () => { if (allDay) setForm(f => ({ ...f, startTime: '09:00', endTime: '11:00' })); else setForm(f => ({ ...f, startTime: '', endTime: '' })); }; return (
); })()}
{(form.startTime || form.endTime) && <>
Von
set('startTime', e.target.value)} style={{ padding: '7px 10px', borderRadius: 9, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 15, color: t.text, fontFamily: 'inherit', fontWeight: 600, fontFeatureSettings: '"tnum"' }}/>
Bis
{(() => { const parseT = (s) => { if (!s) return null; const [h, m] = s.split(':').map(Number); return h + m / 60; }; const a = parseT(form.startTime), b = parseT(form.endTime); if (a == null || b == null || b <= a) return null; const dur = b - a; const mins = Math.round(dur * 60); const label = mins < 60 ? mins + ' Min' : (dur % 1 === 0 ? dur + ' h' : dur.toFixed(1).replace('.', ',') + ' h'); return
Dauer: {label}
; })()}
set('endTime', e.target.value)} style={{ padding: '7px 10px', borderRadius: 9, border: `0.5px solid ${t.inputBorder}`, background: t.inputBg, fontSize: 15, color: t.text, fontFamily: 'inherit', fontWeight: 600, fontFeatureSettings: '"tnum"' }}/>
}
Farbe
{EVENT_COLORS.map((c) => set('color', c)} scale={0.9}>
)}
Notiz · optional
set('note', e.target.value)} placeholder="Abholzeit, Ort, …" style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: `0.5px solid ${t.inputBorder}`, background: t.card, fontSize: 15, color: t.text, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box' }} />
{isEdit && {onDelete(form);onClose();}} scale={0.96}>
Löschen
}
{isEdit ? 'Speichern' : 'Eintrag anlegen'}
); } // ───────────────────────────────────────────────────────────── // Year view (kept tiny) // ───────────────────────────────────────────────────────────── function CalYear({ year, onSelect, events }) { const t = useTheme(); return (
{DE_MONTHS_SHORT.map((m, mi) => { const isCurrent = mi === 4 && year === 2026; const startOff = firstDayCol(year, mi); const dim = daysInMonth(year, mi); const cells = Math.ceil((startOff + dim) / 7) * 7; const monthEvents = events.filter((e) => { const es = fromISO(e.start),ee = fromISO(e.end); return !(ee.getFullYear() < year || es.getFullYear() > year) && !(ee < new Date(year, mi, 1) || es > new Date(year, mi + 1, 0)); }); return ( onSelect(mi)} scale={0.96}>
{m}{isCurrent && }
{Array.from({ length: cells }, (_, i) => { const d = i - startOff + 1; const valid = d >= 1 && d <= dim; const iso = valid ? `${year}-${String(mi + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}` : null; const isToday = iso === todayISO(); const hasBooking = iso && monthEvents.some((e) => iso >= e.start && iso <= e.end); return (
{valid ? d : ''}
); })}
); })}
); } // ─── Day view — vertical timeline with pill cards (like the inspo). // Two strands stacked: // 1) HEUTE — timed events for the current day, anchored to a // hour line on the left; pill cards float to the right. // 2) MEHRTÄGIG — multi-day spans that cross the current day, as a // separate strand below so they never block the day. // Each event = a coloured icon-circle on the line + a rounded card // to the right with title/time/equipment. // ───────────────────────────────────────────────────────────── function CalDay({ dayISO, events, equipment, onAdd, onEdit, onChange, toast }) { const t = useTheme(); const day = dayISO; const parseTime = (s) => { if (!s || typeof s !== 'string' || !/^\d{1,2}:\d{2}$/.test(s)) return null; const [h, m] = s.split(':').map(Number); return h + m / 60; }; const fmtTime = (hr) => { const h = Math.max(0, Math.min(23, Math.floor(hr))); const m = Math.round((hr - h) * 60); return String(h).padStart(2, '0') + ':' + (m === 60 ? '00' : String(m).padStart(2, '0')); }; const dayEvents = events.filter((e) => e.start <= day && e.end >= day); const timed = []; const multi = []; dayEvents.forEach(ev => { const isFirst = ev.start === day; const isLast = ev.end === day; const sHr = isFirst ? parseTime(ev.startTime) : null; const eHr = isLast ? parseTime(ev.endTime) : null; const hasTime = sHr != null && eHr != null && eHr > sHr; const isSpan = ev.start !== ev.end; if (!isSpan && hasTime) timed.push({ ev, sHr, eHr }); // Untimed single-day → treat as a banner (won't blanket the timed strand). else if (!isSpan && !hasTime) multi.push({ ev, isFirst: true, isLast: true, untimed: true }); else multi.push({ ev, isFirst, isLast }); }); timed.sort((a, b) => a.sHr - b.sHr || b.eHr - a.eHr); // Lane-pack overlapping timed events so pills don't sit on top of each other. const laneEnds = []; // each lane stores the last assigned end-hour timed.forEach(s => { let lane = laneEnds.findIndex(end => s.sHr >= end - 0.001); if (lane === -1) { lane = laneEnds.length; laneEnds.push(s.eHr); } else laneEnds[lane] = s.eHr; s.lane = lane; }); const LANE_COUNT = Math.max(1, laneEnds.length); // Geometry — pixels per hour, only render rows for hours that have content // (plus an "all-day" pseudo row for untimed). Actually we draw a continuous // strand spanning min..max hour for compactness. const minHr = timed.length ? Math.max(0, Math.floor(Math.min(...timed.map(x => x.sHr))) - 1) : 8; const maxHr = timed.length ? Math.min(24, Math.ceil(Math.max(...timed.map(x => x.eHr))) + 1) : 20; const HOUR_H = 56; const STRAND_H = (maxHr - minHr) * HOUR_H + 24; const LINE_X = 70; // x-position of the vertical strand const NODE_R = 26; // icon-circle radius const iconFor = (ev) => ev.fromRental ? icons.doc : icons.cal; const now = new Date(); const isToday = day === todayISO(); const nowFrac = isToday ? (now.getHours() + now.getMinutes() / 60) : -1; const TimedStrand = () => (
{/* Hour rail */} {Array.from({ length: maxHr - minHr + 1 }, (_, i) => { const h = minHr + i; const top = i * HOUR_H + 12; return (
{String(h).padStart(2, '0')}:00
); })} {/* Central vertical strand */}
{/* Now-marker */} {nowFrac >= 0 && nowFrac >= minHr && nowFrac <= maxHr && (
)} {/* Event pills */} {timed.map((s, idx) => { const ev = s.ev; const top = (s.sHr - minHr) * HOUR_H + 12; 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 eq = equipment.find(e => e.id === ev.equipmentId); const eqLabel = ev.equipmentLabel || (eq && eq.name) || ''; // Lane shrink — width split between overlapping events const lanePct = 100 / LANE_COUNT; const cardLeftBase = LINE_X + NODE_R + 6; // Pill grows with duration but never shorter than the content needs. const PILL_MIN_H = 66; const pillH = Math.max(dur * HOUR_H, PILL_MIN_H); return (
{/* node — always on the strand */}
{/* pill card — narrowed per lane to avoid overlap */} onEdit && onEdit(ev)} scale={0.98} style={{ position: 'absolute', left: `calc(${cardLeftBase}px + ${s.lane * lanePct}% - ${s.lane * (cardLeftBase + 12) / LANE_COUNT}px)`, width: `calc(${lanePct}% - ${(cardLeftBase + 12) / LANE_COUNT}px - 4px)`, top: 0, height: pillH, }}>
{fmtTime(s.sHr)}–{fmtTime(s.eHr)} · {durLabel} {ev.fromRental && MIETE}
{ev.title}
{eqLabel && (
{eqLabel}
)}
); })} {timed.length === 0 && (
Keine Termine an diesem Tag.
)}
); const MultiStrand = () => { if (!multi.length) return null; const labelFor = (s) => s.untimed ? 'Ganztägig' : 'Mehrtägig'; const heading = multi.every(s => s.untimed) ? 'Ganztägig' : multi.some(s => s.untimed) ? 'Mehrtägig & ganztägig' : 'Mehrtägig'; return (
{heading} · {multi.length} {multi.length === 1 ? 'Eintrag' : 'Einträge'}
{/* vertical line */}
{multi.map((s, i) => { const ev = s.ev; const eq = equipment.find(e => e.id === ev.equipmentId); const totalDays = daysBetween(ev.start, ev.end); const currentIdx = daysBetween(ev.start, day); return (
onEdit && onEdit(ev)} scale={0.99}>
{s.untimed ? labelFor(s) : `Tag ${currentIdx + 1} / ${totalDays}`} {ev.fromRental && MIETE}
{ev.title}
{(ev.equipmentLabel || (eq && eq.name) || '')}{s.untimed ? '' : ' · ' + fmtRange(ev.start, ev.end)}
); })}
); }; return (
{/* Header within the day view (counts only — main header is upstream) */}
{dayEvents.length} {dayEvents.length === 1 ? 'Eintrag' : 'Einträge'} {timed.length > 0 && <>·{timed.length} mit Uhrzeit} {multi.length > 0 && <>·{multi.length} mehrtägig}
{/* HEUTE strand */} {timed.length > 0 || dayEvents.length === 0 ? : null} {/* MEHRTÄGIG strand */} {/* Add button */}
onAdd && onAdd(day)} scale={0.97}>
Eintrag an diesem Tag hinzufügen
); } // ───────────────────────────────────────────────────────────── // Main Kalender screen // ───────────────────────────────────────────────────────────── // Monday of the week containing iso function mondayOf(iso) { const d = fromISO(iso); const wd = (d.getDay() + 6) % 7; // 0 = Monday d.setDate(d.getDate() - wd); return fmtDateISO(d); } function ScreenKalender({ go, events, setEvents, equipment, rentals = [], setRentals, toast }) { const t = useTheme(); const [mode, setMode] = useState('month'); // tag | month | plan | jahr const [labelMode, setLabelMode] = useState(() => { try { const raw = localStorage.getItem('rf-cal-label-mode'); if (raw) return JSON.parse(raw); // One-time migration from legacy underscore key (raw string, no JSON). const legacy = localStorage.getItem('rf_cal_label_mode'); if (legacy) { localStorage.removeItem('rf_cal_label_mode'); return legacy; } return 'name'; } catch { return 'name'; } }); // 'name' | 'detail' useEffect(() => { try { localStorage.setItem('rf-cal-label-mode', JSON.stringify(labelMode)); } catch {} }, [labelMode]); const [view, setView] = useState(() => { const d = new Date(); return { y: d.getFullYear(), m: d.getMonth() }; }); const [weekStart, setWeekStart] = useState(mondayOf(todayISO())); const [selectedDay, setSelectedDay] = useState(null); const [daySheetOpen, setDaySheetOpen] = useState(false); const [eventSheet, setEventSheet] = useState({ open: false, initial: null, day: null }); const [peek, setPeek] = useState(null); // read-only popup: the event being previewed const [confirmEv, setConfirmEv] = useState(null); const [dayViewDate, setDayViewDate] = useState(todayISO()); const calDirRef = React.useRef(0); // -1/+1 slide direction for month/day transitions const [addChoice, setAddChoice] = useState({ open: false, day: null }); const moveWeek = (delta) => { const d = fromISO(weekStart);d.setDate(d.getDate() + delta * 7); setWeekStart(fmtDateISO(d)); }; const goToToday = () => { const d = fromISO(todayISO()); calDirRef.current = 0; setView({ y: d.getFullYear(), m: d.getMonth() }); setDayViewDate(todayISO()); setWeekStart(mondayOf(todayISO())); toast('Heute · ' + fmtDateDE(todayISO())); }; const move = (delta) => { calDirRef.current = Math.sign(delta); const d = new Date(view.y, view.m + delta, 1); setView({ y: d.getFullYear(), m: d.getMonth() }); }; const changeDay = (delta) => { calDirRef.current = Math.sign(delta); const d = fromISO(dayViewDate); d.setDate(d.getDate() + delta); setDayViewDate(fmtDateISO(d)); }; // Horizontal swipe across the calendar body → month (Monat) / day (Tag) nav. const calBodyRef = React.useRef(null); const calSwipe = React.useRef({ x: 0, y: 0, active: false, allowed: false, dir: null }); const calSwipeBlocked = (target) => { let el = target; while (el && el.nodeType === 1) { if (el === calBodyRef.current) return false; // reached our own swipe root → nothing blocked it const cs = window.getComputedStyle(el); if (cs.touchAction === 'none' || cs.touchAction === 'pan-y') return true; if ((cs.overflowX === 'auto' || cs.overflowX === 'scroll') && el.scrollWidth > el.clientWidth + 4) return true; el = el.parentElement; } return false; }; const onCalDown = (e) => { if (e.pointerType === 'mouse') { calSwipe.current.active = false; return; } calSwipe.current = { x: e.clientX, y: e.clientY, active: true, allowed: !calSwipeBlocked(e.target), dir: null }; }; const onCalMove = (e) => { const s = calSwipe.current; if (!s.active || s.dir) return; const dx = e.clientX - s.x, dy = e.clientY - s.y; if (Math.abs(dx) > 12 || Math.abs(dy) > 12) s.dir = Math.abs(dx) > Math.abs(dy) ? 'h' : 'v'; }; const onCalUp = (e) => { const s = calSwipe.current; if (!s.active) return; s.active = false; if (!s.allowed || s.dir !== 'h') return; const dx = e.clientX - s.x, dy = e.clientY - s.y; if (Math.abs(dx) < 56 || Math.abs(dy) > 90) return; const dir = dx < 0 ? 1 : -1; if (mode === 'month') move(dir); else if (mode === 'tag') changeDay(dir); else if (mode === 'jahr') { calDirRef.current = dir; setView(v => ({ y: v.y + dir, m: v.m })); } }; const onPickDay = (iso) => {setSelectedDay(iso);setDaySheetOpen(true);}; // Read-only popup first; the user explicitly clicks "Bearbeiten" to edit. const onPickEvent = (ev) => { setDaySheetOpen(false); setPeek(ev); }; const onAdd = (defaultDay) => { setDaySheetOpen(false); setAddChoice({ open: true, day: defaultDay || todayISO() }); }; const onPickAddType = (kind) => { const day = addChoice.day; setAddChoice({ open: false, day: null }); if (kind === 'event') { setEventSheet({ open: true, initial: null, day }); } else if (kind === 'rental') { go('doc', { newRental: true, defaultStart: day }); } }; const onEditEvent = (ev) => { // Used by the DaySheet's edit action — also routes through peek now. setDaySheetOpen(false); setPeek(ev); }; const onSaveEvent = (ev) => { setEvents((prev) => { const exists = prev.find((p) => p.id === ev.id); if (exists) return prev.map((p) => p.id === ev.id ? ev : p); return [ev, ...prev]; }); toast(eventSheet.initial ? 'Eintrag gespeichert' : `„${ev.title}“ angelegt`); }; const onDeleteEvent = (ev) => { if (ev.fromRental) return; setConfirmEv(ev); }; const doDelete = () => { setEvents((prev) => prev.filter((p) => p.id !== confirmEv.id)); toast('Eintrag gelöscht'); setConfirmEv(null); }; // Single-source change handler — routes back to rentals or events. const onEventChange = (ev) => { if (ev.fromRental && setRentals) { setRentals(prev => prev.map(r => r.id === ev.rentalId ? { ...r, start: ev.start, end: ev.end, startTime: ev.startTime || r.startTime, endTime: ev.endTime || r.endTime, color: ev.color || r.color, } : r)); } else { setEvents(prev => prev.map(p => p.id === ev.id ? ev : p)); } }; // Rentals automatically surface as calendar entries (single source of truth). // Manual calendar events live in `events` and may have no equipment/tenant. // Rentals automatically surface as calendar entries (single source of truth). // ONE bar per rental, regardless of how many equipment items are attached // — a rental is a single booking even when it covers a whole rig. // Manual calendar events live in `events` and may have no equipment/tenant. // Helper: shift "HH:MM" by N minutes (clamped to 00:00–23:59). const shiftTime = (s, mins) => { const m = /^(\d{1,2}):(\d{2})$/.exec(s || ''); if (!m) return ''; const total = Math.max(0, Math.min(24 * 60 - 1, (+m[1]) * 60 + (+m[2]) + mins)); const h = Math.floor(total / 60); const mm = total % 60; return String(h).padStart(2, '0') + ':' + String(mm).padStart(2, '0'); }; const rentalEvents = rentals.flatMap((r) => { const items = getRentalItems(r); const eqLabel = items.length === 0 ? (r.equipmentName || '') : items.length === 1 ? items[0].equipmentName + (items[0].quantity > 1 ? ' ×' + items[0].quantity : '') : items.length + ' Geräte'; const base = { rentalId: r.id, fromRental: true, equipmentId: items[0] ? items[0].equipmentId : r.equipmentId, equipmentLabel: eqLabel, color: r.color || statusMeta(r.status).color, note: r.purpose || '', status: r.status, delivery: r.delivery, itemsCount: items.length || 1, }; const isMulti = r.start !== r.end; // Multi-day rental with times → surface Abholung + Rückgabe as their own // timed events on the respective days, in addition to the multi-day banner. if (isMulti && r.startTime && r.endTime) { return [ { ...base, id: 'r:' + r.id, title: r.tenantName, start: r.start, end: r.end, startTime: '', endTime: '', pickupTime: r.startTime, returnTime: r.endTime }, { ...base, id: 'r:' + r.id + ':pickup', synthetic: 'pickup', title: 'Abholung · ' + r.tenantName, start: r.start, end: r.start, startTime: r.startTime, endTime: shiftTime(r.startTime, 30) }, { ...base, id: 'r:' + r.id + ':return', synthetic: 'return', title: 'Rückgabe · ' + r.tenantName, start: r.end, end: r.end, startTime: shiftTime(r.endTime, -30), endTime: r.endTime }, ]; } // Single-day or timeless rental — keep as one event. return [{ ...base, id: 'r:' + r.id, title: r.tenantName, start: r.start, end: r.end, startTime: r.startTime, endTime: r.endTime, pickupTime: r.startTime, returnTime: r.endTime, }]; }); const mergedEvents = [...rentalEvents, ...events]; // Synthetic Abholung/Rückgabe pills are only useful for day-level route planning. // Everywhere else (month grid, year, upcoming list) we keep just the rental banner. const overviewEvents = mergedEvents.filter((e) => !e.synthetic); const monthLabel = `${DE_MONTHS[view.m]} ${view.y}`; return (
{/* Header */}
{mode === 'tag' ? DE_DAYS[fromISO(dayViewDate).getDay()] : mode === 'jahr' ? 'Übersicht' : mode === 'plan' ? 'Equipment × Zeit' : 'Kalender'}
{mode === 'tag' ? fmtDateDE(dayViewDate) : mode === 'jahr' ? view.y : mode === 'plan' ? 'Belegungsplan' : monthLabel}
Heute
onAdd(mode === 'tag' ? dayViewDate : todayISO())} scale={0.88}>
{/* Mode + month nav */}
{[['tag', 'Tag'], ['month', 'Monat'], ['plan', 'Plan'], ['jahr', 'Jahr']].map(([id, label]) => { calDirRef.current = 0; setMode(id); }} scale={0.96} style={{ flex: 1 }}>
{label}
)}
{mode === 'tag' && <> changeDay(-1)} scale={0.88}>
changeDay(1)} scale={0.88}>
} {mode === 'month' && <> move(-1)} scale={0.88}>
move(1)} scale={0.88}>
}
{/* Body — horizontal swipe = Monat/Tag-Navigation; touch-action: pan-y blockt zugleich den globalen Tab-Wisch hier im Kalender */}
{ calSwipe.current.active = false; }} style={{ touchAction: 'pan-y' }}>
0 ? 'rfSlideR 0.3s cubic-bezier(0.2,0.7,0.3,1)' : calDirRef.current < 0 ? 'rfSlideL 0.3s cubic-bezier(0.2,0.7,0.3,1)' : 'none' }}> {mode === 'tag' && } {mode === 'month' && } {mode === 'plan' && } {mode === 'jahr' && {setView({ y: view.y, m: mi });setMode('month');}} /> } {/* Heutige Termine (only in month) */} {mode === 'month' && <> {(() => { const today = todayISO(); const dObj = fromISO(today); const dateLabel = `${DE_DAYS[dObj.getDay()]}, ${dObj.getDate()}. ${DE_MONTHS[dObj.getMonth()]}`; return (
Termine heute
{dateLabel}
); })()}
{(() => { const today = todayISO(); const list = overviewEvents .filter((e) => e.start <= today && e.end >= today) .sort((a, b) => (a.startTime || a.pickupTime || '99:99').localeCompare(b.startTime || b.pickupTime || '99:99')); if (list.length === 0) return (
Heute keine Einträge.
); return list.map((ev, i) => { const eq = equipment.find(e => e.id === ev.equipmentId); const isRental = !!ev.fromRental; const pu = ev.pickupTime || ev.startTime; const rt = ev.returnTime || ev.endTime; const isStartDay = ev.start === today; const isEndDay = ev.end === today; return ( onPickEvent(ev)} onEdit={() => onPickEvent(ev)} onDelete={isRental ? null : () => onDeleteEvent(ev)}>
{isRental ? (
) : (
{DE_MONTHS_SHORT[fromISO(ev.start).getMonth()]}
{fromISO(ev.start).getDate()}
)}
{ev.title} {isRental && MIETE}
{(ev.equipmentLabel || (eq && eq.name) || 'Allgemein')} · {fmtRange(ev.start, ev.end)}
{isRental && (pu || rt) && (() => { const sd = fromISO(ev.start), ed = fromISO(ev.end); const sLbl = `${String(sd.getDate()).padStart(2,'0')}.${String(sd.getMonth()+1).padStart(2,'0')}.`; const eLbl = `${String(ed.getDate()).padStart(2,'0')}.${String(ed.getMonth()+1).padStart(2,'0')}.`; return (
↑ Abholung {sLbl} {pu ? pu + ' Uhr' : '–'} · ↓ Rückgabe {eLbl} {rt ? rt + ' Uhr' : '–'}
); })()}
); }); })()}
}
setDaySheetOpen(false)} events={mergedEvents} equipment={equipment} onAdd={onAdd} onEdit={onEditEvent} onDelete={onDeleteEvent} /> {/* Read-only quick-view bottom sheet */} setPeek(null)} maxHeight="86%"> {peek && (() => { const ev = peek; const rental = ev.fromRental ? rentals.find(r => r.id === ev.rentalId) : null; const its = rental ? getRentalItems(rental) : (ev.equipmentId ? [{ equipmentId: ev.equipmentId, equipmentName: (equipment.find(e => e.id === ev.equipmentId) || {}).name, quantity: 1, dailyRate: (equipment.find(e => e.id === ev.equipmentId) || {}).price }] : []); const days = daysBetween(ev.start, ev.end); const sm = rental ? statusMeta(rental.status) : (ev.status ? statusMeta(ev.status) : null); const subtotal = rental ? (window.rentalSubtotal ? window.rentalSubtotal(rental) : 0) : 0; const total = rental ? (window.rentalTotal ? window.rentalTotal(rental) : 0) : 0; const deliveryFee = rental && window.rentalDeliveryFee ? window.rentalDeliveryFee(rental) : 0; const onEdit = () => { setPeek(null); if (ev.fromRental) { go('doc', { rentalId: ev.rentalId, origin: 'cal' }); } else { setEventSheet({ open: true, initial: ev, day: null }); } }; const onColorChange = (c) => { onEventChange({ ...ev, color: c }); setPeek({ ...ev, color: c }); }; return (
{/* Header */}
{ev.fromRental ? 'Mietvertrag' : 'Termin'}
{sm && ( ● {sm.label} )}
{ev.title}
setPeek(null)} scale={0.85}>
{/* Quick stats row — period + total */}
Zeitraum
{fmtRange(ev.start, ev.end)}
{days} Tag{days > 1 ? 'e' : ''}{(ev.startTime || ev.endTime) ? ` · ${ev.startTime || '–'}–${ev.endTime || '–'}` : ''}
{rental && (
Gesamtpreis
{total} €
{rental.paymentStatus === 'bezahlt' ? ● Bezahlt : ● Offen}
)}
{/* Abholung / Rückgabe — nur bei Mieten */} {rental && (rental.startTime || rental.endTime) && (
{(() => { const sd = fromISO(rental.start), ed = fromISO(rental.end); const sLbl = `${sd.getDate()}. ${DE_MONTHS_SHORT[sd.getMonth()]}`; const eLbl = `${ed.getDate()}. ${DE_MONTHS_SHORT[ed.getMonth()]}`; const Tile = ({ arrow, color, label, dateLbl, time }) => (
{arrow} {label}
{time || '–'}{time ? ' Uhr' : ''}
{dateLbl}
); return ( <> ); })()}
)} {/* Tenant block — only for rentals */} {rental && ( <>
Mieter
{rental.tenantName}
{rental.phone &&
{rental.phone}
} {rental.email &&
{rental.email}
} {rental.address &&
{rental.address}
}
)} {/* Equipment list */} {its.length > 0 && ( <>
Equipment · {its.length}{its.length > 1 ? ' Positionen' : ' Position'}
{its.map((it, idx) => { const eq = equipment.find(e => e.id === it.equipmentId); const isPair = (window.isPairEq && window.isPairEq(eq)) || (it.isPair); const perPiece = isPair ? (Number(it.dailyRate) || 0) / 2 : (Number(it.dailyRate) || 0); const sum = days * perPiece * Math.max(1, Number(it.quantity) || 1); return (
{eq && }
{it.equipmentName || (eq && eq.name)}
{it.quantity} {isPair ? 'Stück' : (it.quantity > 1 ? 'Stück' : 'Stück')} · {perPiece} €/Tag{isPair ? ' (Stück)' : ''}
{sum} €
); })} {rental && (
Summe · {days} Tag{days > 1 ? 'e' : ''}{deliveryFee > 0 ? ` · inkl. ${deliveryFee.toFixed(2).replace('.', ',')} € Lieferung` : ''}
{subtotal} €
)}
)} {/* Delivery info — for rentals */} {rental && rental.delivery && rental.delivery.enabled && ( <>
Lieferung
{rental.delivery.address || rental.address || '—'}
{rental.delivery.km || 0} km einfach · Pauschale + km × Satz
)} {/* Notes */} {(ev.note || (rental && (rental.note || rental.purpose))) && ( <>
Notiz
{ev.note || (rental && rental.note) || (rental && rental.purpose)}
)} {/* Calendar color — editable inline */}
Farbe im Kalender
{EVENT_COLORS.map(c => { const sel = ev.color === c; return ( onColorChange(c)} scale={0.88}>
); })}
{/* Action bar */}
setPeek(null)} scale={0.97} style={{ flex: 1 }}>
Schließen
Bearbeiten
); })()} setEventSheet({ open: false, initial: null, day: null })} initial={eventSheet.initial} defaultDate={eventSheet.day} equipment={equipment} onSave={onSaveEvent} onDelete={onDeleteEvent} /> setConfirmEv(null)} onConfirm={doDelete} /> {/* +Eintrag choice — Termin vs neue Miete */} setAddChoice({ open: false, day: null })}>
Was anlegen?
{addChoice.day && fmtDateDE(addChoice.day)}
setAddChoice({ open: false, day: null })}>
Abbrechen
onPickAddType('event')} scale={0.98}>
Allgemeiner Eintrag
Termin · Erinnerung · Notiz
onPickAddType('rental')} scale={0.98}>
Neue Miete
Mietvertrag mit Equipment + Mieter
); } Object.assign(window, { ScreenKalender, MiniRangePicker, CalPlanView, CalMonthView });