// RentFlow Manager — Interactive prototype (root file) // State lifted to App; screens are split across screen-*.jsx files. const { useState, useRef, useEffect, useMemo } = React; // ───────────────────────────────────────────────────────────── // Server-gestützter State-Helper (Name "useLocal" aus Kompatibilität mit allen // Aufrufstellen beibehalten — speichert aber NICHTS mehr lokal). // // Der Anfangswert kommt ausschließlich aus window.RFDB.get(key), das von // db.js VOR dem Mounten der App einmalig komplett vom Server geladen wurde // (siehe index.html — die App wird gar nicht erst gemountet, wenn dieser // Abruf fehlschlägt). Jede Änderung wird sofort zum Server geschrieben; // schlägt das fehl, meldet RFDB.set() das über ein 'rfdb-error'-Event // (App zeigt dazu ein dauerhaftes Fehler-Banner, siehe unten) — der Wert // wird dabei NIE lokal zwischengespeichert oder stillschweigend verworfen. // ───────────────────────────────────────────────────────────── function useLocal(key, init) { const [v, setV] = useState(() => { const server = window.RFDB ? window.RFDB.get(key) : undefined; return server !== undefined ? server : init; }); const first = useRef(true); useEffect(() => { // Den allerersten Render (= der gerade vom Server geladene Wert) nicht // gleich wieder zurückschreiben — nur echte Änderungen senden. if (first.current) { first.current = false; return; } if (window.RFDB) window.RFDB.set(key, v).catch(() => {}); // Fehler-Handling via 'rfdb-error'-Event }, [key, v]); return [v, setV]; } // ───────────────────────────────────────────────────────────── // Body scroll lock — the page is the scroll container now, so while a modal // is open we freeze document scroll (ref-counted to handle stacked overlays). // ───────────────────────────────────────────────────────────── let __rfScrollLocks = 0; function useBodyScrollLock(active) { useEffect(() => { if (!active) return; __rfScrollLocks += 1; document.body.style.overflow = 'hidden'; return () => { __rfScrollLocks = Math.max(0, __rfScrollLocks - 1); if (__rfScrollLocks === 0) document.body.style.overflow = ''; }; }, [active]); } // ───────────────────────────────────────────────────────────── // Pressable // ───────────────────────────────────────────────────────────── function Pressable({ children, onClick, style = {}, scale = 0.97, disabled }) { const [pressed, setPressed] = useState(false); return (
!disabled && setPressed(true)} onPointerUp={() => setPressed(false)} onPointerLeave={() => setPressed(false)} onClick={disabled ? undefined : onClick} style={{ cursor: disabled ? 'default' : 'pointer', transform: pressed ? `scale(${scale})` : 'scale(1)', transition: 'transform 0.12s cubic-bezier(0.2,0.7,0.3,1), opacity 0.12s', opacity: pressed ? 0.85 : (disabled ? 0.45 : 1), ...style, }} > {children}
); } // ───────────────────────────────────────────────────────────── // Toast // ───────────────────────────────────────────────────────────── function Toast({ msg, onClose }) { const t = useTheme(); useEffect(() => { if (!msg) return; const tm = setTimeout(onClose, 2400); return () => clearTimeout(tm); }, [msg]); return (
{msg}
); } // ───────────────────────────────────────────────────────────── // ErrorBanner — dauerhafter Hinweis, wenn ein Schreibvorgang zum Server // fehlgeschlagen ist (siehe db.js: window.RFDB.set/remove/importAll/resetAll // lösen dann ein 'rfdb-error'-Event aus). Verschwindet NICHT von selbst nach // ein paar Sekunden wie der normale Toast — bewusst, damit eine nicht // gespeicherte Änderung nicht unbemerkt bleibt. Schließt sich nur, wenn der // Nutzer es aktiv wegtippt. // ───────────────────────────────────────────────────────────── function useRfdbErrorBanner() { const [err, setErr] = useState(null); useEffect(() => { const onErr = (e) => setErr(e.detail); window.addEventListener('rfdb-error', onErr); return () => window.removeEventListener('rfdb-error', onErr); }, []); return [err, () => setErr(null)]; } function ErrorBanner({ err, onClose }) { const t = useTheme(); if (!err) return null; const label = { save: 'Änderung konnte nicht gespeichert werden.', delete: 'Löschen ist fehlgeschlagen.', import: 'Sicherung konnte nicht wiederhergestellt werden.', reset: 'Zurücksetzen ist fehlgeschlagen.', }[err.kind] || 'Verbindung zum Server fehlgeschlagen.'; return (
{label}
Bitte Internetverbindung prüfen und die Änderung erneut vornehmen.
); } // ───────────────────────────────────────────────────────────── // LongPressRow — tap = onTap; swipe-left = reveal red "Löschen" action; // long-press = floating Bearbeiten/Löschen menu. // Drop-in replacement for a Pressable list row that needs these actions. // ───────────────────────────────────────────────────────────── function LongPressRow({ children, onTap, onEdit, onDelete, style, bg, longDelay = 480, swipeDelete = true }) { const t = useTheme(); const startX = React.useRef(0); const startY = React.useRef(0); const moved = React.useRef(false); const dir = React.useRef(null); // 'h' | 'v' | null — locked gesture axis const baseOffset = React.useRef(0); // offset at gesture start const longTimer = React.useRef(null); const longFired = React.useRef(false); const rowRef = React.useRef(null); const [menu, setMenu] = React.useState(null); const [offset, setOffset] = React.useState(0); const [dragging, setDragging] = React.useState(false); const hasMenu = !!(onEdit || onDelete); const canSwipe = swipeDelete && !!onDelete; const ACTION_W = 84; const cancelLong = () => { if (longTimer.current) { clearTimeout(longTimer.current); longTimer.current = null; } }; const close = () => { baseOffset.current = 0; setOffset(0); }; const open = () => { baseOffset.current = -ACTION_W; setOffset(-ACTION_W); }; const onPointerDown = (ev) => { if (ev.pointerType === 'mouse' && ev.button !== 0) return; startX.current = ev.clientX; startY.current = ev.clientY; moved.current = false; longFired.current = false; dir.current = null; baseOffset.current = offset; cancelLong(); if (hasMenu) { longTimer.current = setTimeout(() => { if (dir.current === 'h') return; longFired.current = true; const rect = rowRef.current && rowRef.current.getBoundingClientRect(); if (rect) setMenu({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }); }, longDelay); } try { ev.currentTarget.setPointerCapture(ev.pointerId); } catch {} }; const onPointerMove = (ev) => { const dx = ev.clientX - startX.current; const dy = ev.clientY - startY.current; if (dir.current === null) { if (Math.abs(dx) > 6 || Math.abs(dy) > 6) { dir.current = Math.abs(dx) > Math.abs(dy) ? 'h' : 'v'; moved.current = true; cancelLong(); if (dir.current === 'h' && canSwipe) setDragging(true); } } if (dir.current === 'h' && canSwipe) { const next = Math.max(-ACTION_W, Math.min(0, baseOffset.current + dx)); setOffset(next); } }; const settle = () => { cancelLong(); if (dir.current === 'h' && canSwipe) { setDragging(false); if (offset < -ACTION_W / 2) open(); else close(); return; } if (longFired.current || moved.current) return; if (offset !== 0) { close(); return; } // tap on an open row → just close it if (onTap) onTap(); }; const onCancel = () => { cancelLong(); if (dir.current === 'h' && canSwipe) { setDragging(false); if (offset < -ACTION_W / 2) open(); else close(); } }; return (
{canSwipe && offset < 0 && (
{ onDelete(); close(); }} scale={0.96} style={{ flex: 1 }}>
Löschen
)}
{children}
{menu && setMenu(null)} onEdit={onEdit ? () => { setMenu(null); onEdit(); } : null} onDelete={onDelete ? () => { setMenu(null); onDelete(); } : null}/>}
); } function RowContextMenu({ pos, onClose, onEdit, onDelete }) { const t = useTheme(); const items = []; if (onEdit) items.push({ id: 'edit', label: 'Bearbeiten', color: t.text }); if (onDelete) items.push({ id: 'del', label: 'Löschen', color: t.red }); const W = 220; const H = items.length * 50 + 4; const vw = typeof window !== 'undefined' ? window.innerWidth : 360; const vh = typeof window !== 'undefined' ? window.innerHeight : 720; const left = Math.max(12, Math.min(vw - W - 12, pos.x - W / 2)); const top = Math.max(12, Math.min(vh - H - 12, pos.y - H / 2)); return (
e.stopPropagation()} style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.25)', backdropFilter: 'blur(2px)', WebkitBackdropFilter: 'blur(2px)' }}/>
{items.map((it, i) => { const handler = it.id === 'edit' ? onEdit : onDelete; return (
{it.label} {it.id === 'edit' ? : }
); })}
); } // ───────────────────────────────────────────────────────────── // Modal sheet // ───────────────────────────────────────────────────────────── function Sheet({ open, onClose, children, maxHeight = '85%' }) { const t = useTheme(); useBodyScrollLock(open); const [dragY, setDragY] = React.useState(0); const [dragging, setDragging] = React.useState(false); const [visible, setVisible] = React.useState(open); const startY = React.useRef(0); React.useEffect(() => { setDragY(0); setDragging(false); }, [open]); // Keep the panel rendered through its close animation, then hard-hide it so a // closed sheet can never "peek" into view during page scroll (iOS fixed-paint quirk). React.useEffect(() => { if (open) { setVisible(true); return; } const id = setTimeout(() => setVisible(false), 360); return () => clearTimeout(id); }, [open]); const onGrabDown = (e) => { startY.current = e.clientY; setDragging(true); try { e.currentTarget.setPointerCapture(e.pointerId); } catch {} }; const onGrabMove = (e) => { if (!dragging) return; setDragY(Math.max(0, e.clientY - startY.current)); }; const onGrabUp = (e) => { setDragging(false); const dy = e.clientY - startY.current; if (dy > 100) { onClose && onClose(); } else setDragY(0); }; return ReactDOM.createPortal( <>
{ setDragging(false); setDragY(0); }} style={{ display: 'flex', justifyContent: 'center', padding: '10px 0 8px', position: 'sticky', top: 0, background: t.sheetBg, zIndex: 2, touchAction: 'none', cursor: 'grab' }}>
{children}
, document.body ); } // ───────────────────────────────────────────────────────────── // Confirm dialog // ───────────────────────────────────────────────────────────── function Confirm({ open, title, message, confirmLabel = 'Löschen', danger = true, onCancel, onConfirm }) { const t = useTheme(); useBodyScrollLock(open); return ReactDOM.createPortal( <>
{title}
{message &&
{message}
}
Abbrechen
{confirmLabel}
, document.body ); } // ───────────────────────────────────────────────────────────── // Inline editable field — tap to edit // ───────────────────────────────────────────────────────────── function Field({ label, value, onChange, placeholder = '—', multiline = false, type = 'text' }) { const t = useTheme(); const [editing, setEditing] = useState(false); const [tmp, setTmp] = useState(value || ''); useEffect(() => { setTmp(value || ''); }, [value]); const save = () => { setEditing(false); if (tmp !== value) onChange(tmp); }; return (
{label}
{editing ? ( multiline ? (