// Desktop Dokumente — three tabs: // • Vorlagen — printable PDF templates (Mietvertrag/Rechnung/Übergabeprotokoll), letterhead preview // • Schnelltexte — WhatsApp/Kleinanzeigen text templates with copy-to-clipboard // • Archiv — folder-organized archive of all generated docs, linked to rentals // // All generated documents are stored in the `docs` array (app-level state) and // shown on the corresponding Mietvorgang detail pane. const DEFAULT_TEMPLATES = [ { id: 'vertrag', name: 'Mietvertrag', kind: 'vertrag', icon: 'fileText', color: '#007AFF', builtIn: true, title: 'Mietvertrag', fields: { mietgegenstand: 'Vermietet wird {{equipment}}{{zubehoer_satz}} Verwendungszweck: {{zweck}}.', mietdauer: 'Vom {{start}} ({{startzeit}} Uhr, Abholung) bis {{ende}} ({{endezeit}} Uhr, Rückgabe) — {{tage}}.', pflichten: 'Der Mieter verpflichtet sich, den Mietgegenstand pfleglich zu behandeln und in ordnungsgemäßem Zustand zurückzugeben. Schäden sind unverzüglich zu melden.', }, }, { id: 'rechnung', name: 'Rechnung', kind: 'rechnung', icon: 'invoice', color: '#34C759', builtIn: true, title: 'Rechnung', fields: { zahlungshinweis: 'Zahlbar innerhalb 14 Tagen ohne Abzug auf folgendes Konto:\n{{iban}} · Verwendungszweck: {{rechnungsnr}}', }, }, { id: 'protokoll', name: 'Übergabeprotokoll', kind: 'protokoll', icon: 'doc', color: '#AF52DE', builtIn: true, title: 'Übergabeprotokoll', fields: { zustandsoptionen: 'Einwandfrei | Gebrauchsspuren | Mängel (s. Notiz)', }, }, ]; const TOKENS_LIST = [ ['mieter', 'Name des Mieters'], ['adresse', 'Anschrift'], ['telefon', 'Telefon'], ['email', 'E-Mail'], ['ausweis', 'Ausweis-Nr.'], ['equipment', 'Equipment-Name'], ['zubehoer', 'Zubehör (kommagetrennt)'], ['zweck', 'Verwendungszweck'], ['start', 'Startdatum'], ['ende', 'Enddatum'], ['startzeit', 'Abholzeit'], ['endezeit', 'Rückgabezeit'], ['tage', 'Mietdauer (Tage)'], ['summe', 'Mietsumme'], ['tagespreis', 'Tagespreis'], ['kaution', 'Kaution'], ['firma', 'Firma'], ['iban', 'IBAN'], ['datum', 'Heutiges Datum'], ['rechnungsnr', 'Rechnungsnummer'], ['vertragsnr', 'Vertragsnummer'], ]; function buildTokens(r, eq, company) { const days = daysBetween(r.start, r.end); return { mieter: r.tenantName, adresse: r.address || '—', telefon: r.phone || '—', email: r.email || '—', ausweis: r.idCard || '—', equipment: eq ? eq.name : r.equipmentName, zubehoer: eq && eq.accessories && eq.accessories.length ? eq.accessories.join(', ') : '', zubehoer_satz: eq && eq.accessories && eq.accessories.length ? ` inkl. Zubehör: ${eq.accessories.join(', ')}.` : '.', zweck: r.purpose || 'allgemeine Nutzung', start: fmtDateDE(r.start), ende: fmtDateDE(r.end), startzeit: r.startTime || '—', endezeit: r.endTime || '—', tage: days + ' Tag' + (days > 1 ? 'e' : ''), summe: rentalTotal(r) + ' €', tagespreis: r.dailyRate + ' €', kaution: r.deposit + ' €', firma: company.name, iban: company.iban, datum: fmtDateDE(todayISO()), rechnungsnr: 'RE-2026-' + r.id.replace(/\D/g, '').padStart(3, '0'), vertragsnr: 'MV-2026-' + r.id.replace(/\D/g, '').padStart(3, '0'), }; } const sub = (txt, tok) => (txt || '').replace(/\{\{(\w+)\}\}/g, (_, k) => tok[k] !== undefined ? tok[k] : `{{${k}}}`); // ── Top-level screen with left-side tabs ── function ScreenDocuments({ go, nav, rentals, equipment, company, docs, setDocs }) { const t = useTheme(); const [tab, setTab] = useState((nav && nav.docsTab) || 'vorlagen'); const [initialRentalId, setInitialRentalId] = useState(null); const [initialDocId, setInitialDocId] = useState(null); const [templates, setTemplates] = useLocal('rf.docTemplates.v1', DEFAULT_TEMPLATES); useEffect(() => { if (!nav) return; if (nav.docsTab) setTab(nav.docsTab); if (nav.rentalId) setInitialRentalId(nav.rentalId); if (nav.docId) setInitialDocId(nav.docId); }, [nav]); const TABS = [ { id: 'vorlagen', label: 'Vorlagen', sub: 'Verträge · Rechnungen', icon: icons.fileText, color: '#007AFF' }, { id: 'schnelltexte', label: 'Schnelltexte', sub: 'WhatsApp · Inserate', icon: icons.mail, color: '#34C759' }, { id: 'archiv', label: 'Archiv', sub: `${docs.length} Dokumente`, icon: icons.archive, color: '#AF52DE' }, ]; return ( <> x.id === tab).sub}> [x.id, x.label, x.id === 'archiv' ? docs.length : null])} />
{/* Active tab content */} {tab === 'vorlagen' && } {tab === 'schnelltexte' && } {tab === 'archiv' && }
); } // ── Vorlagen tab — generate printable PDFs ── function DocsVorlagen({ rentals, equipment, company, templates, setTemplates, docs, setDocs, initialRentalId }) { const t = useTheme(); const toast = useToast(); const [selRental, setSelRental] = useState(initialRentalId || (rentals[0] ? rentals[0].id : null)); const [selTpl, setSelTpl] = useState(templates[0] ? templates[0].id : null); const [editing, setEditing] = useState(false); const [confirmDel, setConfirmDel] = useState(null); const [zoom, setZoom] = useState(1); const [rentalMenu, setRentalMenu] = useState(false); const fileRef = useRef(null); const scrollRef = useRef(null); const rentalBtnRef = useRef(null); useEffect(() => { if (initialRentalId) setSelRental(initialRentalId); }, [initialRentalId]); const r = rentals.find((x) => x.id === selRental); const eq = r && equipment.find((e) => e.id === r.equipmentId); const tpl = templates.find((x) => x.id === selTpl) || templates[0]; // Mousewheel zoom on the preview pane — centered around the cursor useEffect(() => { const el = scrollRef.current; if (!el) return; const onWheel = (e) => { // Cmd/Ctrl + wheel = zoom. Plain wheel scrolls normally. if (!(e.metaKey || e.ctrlKey)) return; e.preventDefault(); const delta = -e.deltaY * 0.0018; setZoom((z) => { const next = Math.max(0.4, Math.min(2.5, z + delta)); return Math.round(next * 100) / 100; }); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, []); // Close rental dropdown when clicking outside useEffect(() => { if (!rentalMenu) return; const close = (e) => { if (rentalBtnRef.current && !rentalBtnRef.current.contains(e.target)) setRentalMenu(false); }; document.addEventListener('mousedown', close); return () => document.removeEventListener('mousedown', close); }, [rentalMenu]); const patchTpl = (patch) => setTemplates((prev) => prev.map((x) => x.id === tpl.id ? { ...x, ...patch } : x)); const patchField = (k, v) => patchTpl({ fields: { ...tpl.fields, [k]: v } }); const resetTpl = () => { const def = DEFAULT_TEMPLATES.find((d) => d.id === tpl.id); if (def) { setTemplates((prev) => prev.map((x) => x.id === tpl.id ? def : x)); toast('Vorlage zurückgesetzt'); } }; const deleteTpl = (id) => { setTemplates((prev) => prev.filter((x) => x.id !== id)); if (selTpl === id) setSelTpl(templates[0] ? templates[0].id : null); toast('Vorlage gelöscht'); }; const onUpload = (e) => { const file = e.target.files && e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { const isText = /\.(txt|md|html?)$/i.test(file.name); const body = isText ? String(reader.result) : `Sehr geehrte/r {{mieter}},\n\nhiermit bestätigen wir die Vermietung von {{equipment}} im Zeitraum {{start}} – {{ende}}.\n\nMietsumme: {{summe}}\nKaution: {{kaution}}\n\nMit freundlichen Grüßen\n{{firma}}`; const id = 'tpl-' + Date.now(); const name = file.name.replace(/\.[^.]+$/, ''); setTemplates((prev) => [...prev, { id, name, kind: 'custom', icon: 'fileText', color: '#FF9500', builtIn: false, title: name, fileName: file.name, fields: { body }, }]); setSelTpl(id); setEditing(true); toast(`„${file.name}" als Vorlage importiert`); }; if (/\.(txt|md|html?)$/i.test(file.name)) reader.readAsText(file); else reader.readAsArrayBuffer(file); e.target.value = ''; }; const saveToArchive = () => { if (!r || !tpl) return; const tok = buildTokens(r, eq, company); const number = tpl.kind === 'rechnung' ? tok.rechnungsnr : tpl.kind === 'vertrag' ? tok.vertragsnr : null; const doc = { id: 'd-' + Date.now(), kind: tpl.kind, format: 'pdf', title: number ? `${tpl.name} ${number}` : `${tpl.name} · ${r.tenantName}`, rentalId: r.id, templateId: tpl.id, number, createdISO: todayISO(), }; setDocs((prev) => [doc, ...prev]); toast('Im Archiv gespeichert'); }; const standardTpls = templates.filter((x) => x.builtIn); const customTpls = templates.filter((x) => !x.builtIn); const renderTplRow = (d) => { const sel = selTpl === d.id; return ( setSelTpl(d.id)} scale={0.99} hoverBg={sel ? undefined : t.cardAlt}>
{d.name}
{d.builtIn ? 'Standard-Vorlage' : (d.fileName || 'Eigene Vorlage')}
{!d.builtIn && ( { e.stopPropagation(); setConfirmDel(d); }} scale={0.9}>
)}
); }; const zoomPct = Math.round(zoom * 100); const zoomBtn = (label, onClick, disabled) => (
{label}
); return (
{/* ── Sidebar: templates ── */}
Vorlagen
fileRef.current && fileRef.current.click()} scale={0.94}>
Hochladen
Standard
{standardTpls.map(renderTplRow)} {customTpls.length > 0 && ( <>
Eigene
{customTpls.map(renderTplRow)} )}
{/* ── Editor (when bearbeiten is on) ── */} {editing && tpl && ( setConfirmDel(tpl)}/> )} {/* ── Preview pane ── */}
{/* Action bar */} {r && tpl && (
{/* Mietvorgang dropdown */}
setRentalMenu((v) => !v)} scale={0.98}>
{r.tenantName}
{eq ? eq.name : r.equipmentName}
{rentalMenu && (
{rentals.map((x) => { const e = equipment.find((q) => q.id === x.equipmentId); const sel = x.id === selRental; return ( { setSelRental(x.id); setRentalMenu(false); }} scale={0.99} hoverBg={sel ? undefined : t.cardAlt}>
{x.tenantName}
{e ? e.name : x.equipmentName}
); })}
)}
{/* Zoom controls */}
{zoomBtn('−', () => setZoom((z) => Math.max(0.4, Math.round((z - 0.1) * 100) / 100)), zoom <= 0.4)} setZoom(1)} scale={0.96}>
{zoomPct}%
{zoomBtn('+', () => setZoom((z) => Math.min(2.5, Math.round((z + 0.1) * 100) / 100)), zoom >= 2.5)}
{!editing && } {!editing && } {!editing && }
)} {/* Scrollable paper area */}
{r && tpl ? (
) : }
{ if (confirmDel.builtIn) resetTpl(); else deleteTpl(confirmDel.id); }} onClose={() => setConfirmDel(null)}/>
); } // ── Template editor (left panel when "Bearbeiten" is on) ── function TemplateEditor({ t, tpl, onPatchTpl, onPatchField, onReset, onDelete }) { const fieldDefs = tpl.kind === 'vertrag' ? [ { key: 'mietgegenstand', label: '§1 Mietgegenstand', rows: 3 }, { key: 'mietdauer', label: '§2 Mietdauer', rows: 2 }, { key: 'pflichten', label: '§4 Pflichten des Mieters', rows: 4 }, ] : tpl.kind === 'rechnung' ? [ { key: 'zahlungshinweis', label: 'Zahlungshinweis (Fußzeile)', rows: 3 }, ] : tpl.kind === 'protokoll' ? [ { key: 'zustandsoptionen', label: 'Zustands-Optionen (mit | getrennt)', rows: 2 }, ] : [ { key: 'body', label: 'Dokument-Inhalt', rows: 16 }, ]; const ta = { width: '100%', fontFamily: 'inherit', fontSize: 13, lineHeight: 1.55, padding: '10px 12px', border: `1px solid ${t.sep}`, borderRadius: 9, background: t.inputBg, color: t.text, resize: 'vertical', outline: 'none', boxSizing: 'border-box', }; return (
Vorlage bearbeiten
onPatchTpl({ name: e.target.value, title: e.target.value })} style={{ width: '100%', fontSize: 19, fontWeight: 700, color: t.text, background: 'transparent', border: 'none', outline: 'none', padding: '2px 0', letterSpacing: -0.3, boxSizing: 'border-box' }}/>
{tpl.builtIn ? 'Standard-Vorlage' : (tpl.fileName || 'Eigene Vorlage')}
{fieldDefs.map((f) => (
{f.label}