// Nachrichten — kontextstart, vorlagen mit auto-fill, geteilter posteingang, lesebestätigungen // ───── Eigentümer (Team) ───── const OWNERS = [ { id: "o1", name: "Marie Tessmann", initials: "MT", role: "Eigentümer", color: "#2d4a3e" }, { id: "o2", name: "Stefan Tessmann", initials: "ST", role: "Mit-Eigentümer", color: "#7a5a2e" }, { id: "o3", name: "Lisa Voigt", initials: "LV", role: "Verwalterin", color: "#5a4a7a" }, ]; const ME = "o1"; // current user // ───── Vorlagen ───── // Each template: id, name, category, channel, subject, build({tenant, unit, property, ctx}) -> body const TEMPLATES = [ { id: "rent_increase", name: "Mieterhöhung (Indexmiete)", category: "Vertrag", icon: "↗", channel: "email", subject: "Mietanpassung gemäß Indexmiete", fields: [ { id: "newRent", label: "Neue Kaltmiete", type: "currency", default: (ctx) => ctx.unit ? Math.round(ctx.unit.rent * 1.038) : 0 }, { id: "fromDate", label: "Gültig ab", type: "date", default: () => "2026-09-01" }, { id: "indexPct", label: "Indexsteigerung", type: "percent", default: () => "3,8" }, ], build: ({ tenant, unit, property, fields }) => { const oldRent = unit?.rent || 0; const newRent = fields.newRent || 0; const diff = newRent - oldRent; const pct = fields.indexPct || "3,8"; return `Sehr geehrte/r ${tenant?.name || "Mieter/in"}, gemäß §557b BGB und der vereinbarten Indexmiete-Klausel in Ihrem Mietvertrag passen wir die Kaltmiete für ${unit?.label || "Ihre Wohnung"} (${property?.address || ""}) wie folgt an: Bisherige Kaltmiete: ${oldRent.toLocaleString('de-DE')} € Neue Kaltmiete: ${newRent.toLocaleString('de-DE')} € Erhöhung: ${diff > 0 ? "+" : ""}${diff.toLocaleString('de-DE')} € (${pct} %) Gültig ab: ${fmtDateLong(fields.fromDate)} Grundlage: Verbraucherpreisindex des Statistischen Bundesamts. Die Berechnung finden Sie im Anhang. Bitte passen Sie Ihren Dauerauftrag rechtzeitig an. Bei Rückfragen melden Sie sich gern. Mit freundlichen Grüßen Marie Tessmann`; }, }, { id: "appointment", name: "Terminvorschlag", category: "Termin", icon: "📅", channel: "email", subject: "Terminvorschlag", fields: [ { id: "reason", label: "Anlass", type: "text", default: () => "Wohnungsbesichtigung" }, { id: "date", label: "Datum", type: "date", default: () => "2026-05-12" }, { id: "time", label: "Uhrzeit", type: "text", default: () => "14:00 – 15:00 Uhr" }, ], build: ({ tenant, unit, property, fields }) => `Hallo ${firstName(tenant?.name)}, ich würde gern einen Termin mit Ihnen vereinbaren — Anlass: ${fields.reason}. Mein Vorschlag: ${fmtDateLong(fields.date)} · ${fields.time} ${property?.address || ""}${unit ? ", " + unit.label : ""} Passt das? Falls nicht, schlagen Sie gern eine Alternative vor. Viele Grüße Marie Tessmann`, }, { id: "dunning", name: "Mahnung Mietrückstand", category: "Forderung", icon: "!", channel: "email", subject: "Mietrückstand — Zahlungserinnerung", fields: [ { id: "amount", label: "Offener Betrag", type: "currency", default: (ctx) => ctx.unit?.rent || 0 }, { id: "month", label: "Betroffener Monat", type: "text", default: () => "Mai 2026" }, { id: "deadline", label: "Zahlungsfrist", type: "date", default: () => "2026-05-15" }, ], build: ({ tenant, unit, fields }) => `Sehr geehrte/r ${tenant?.name || "Mieter/in"}, unsere Buchhaltung verzeichnet für ${fields.month} einen Mietrückstand für ${unit?.label || ""} in Höhe von ${(fields.amount || 0).toLocaleString('de-DE')} €. Bitte überweisen Sie den Betrag bis spätestens ${fmtDateLong(fields.deadline)} auf das bekannte Konto. Sollte die Zahlung bereits erfolgt sein, betrachten Sie dieses Schreiben als gegenstandslos. Bei Liquiditätsproblemen melden Sie sich bitte umgehend — wir finden gemeinsam eine Lösung. Mit freundlichen Grüßen Marie Tessmann`, }, { id: "maintenance_confirm", name: "Wartungstermin bestätigt", category: "Wartung", icon: "🔧", channel: "email", subject: "Wartungstermin", fields: [ { id: "issue", label: "Mangel/Arbeit", type: "text", default: (ctx) => ctx.maintenanceTitle || "Reparatur" }, { id: "tradesperson", label: "Handwerker", type: "text", default: () => "Klempnerei Schmidt" }, { id: "date", label: "Termin", type: "date", default: () => "2026-05-08" }, { id: "window", label: "Zeitfenster", type: "text", default: () => "10:00 – 12:00 Uhr" }, ], build: ({ tenant, fields }) => `Hallo ${firstName(tenant?.name)}, danke für Ihre Meldung. Ich habe einen Termin für „${fields.issue}" organisiert: ${fields.tradesperson} ${fmtDateLong(fields.date)} · ${fields.window} Bitte stellen Sie sicher, dass jemand zuhause ist oder hinterlegen Sie den Schlüssel. Der Handwerker meldet sich vorher kurz telefonisch. Viele Grüße Marie Tessmann`, }, { id: "nk_settlement", name: "Nebenkostenabrechnung", category: "Abrechnung", icon: "Σ", channel: "email", subject: "Nebenkostenabrechnung — Ihr Saldo", fields: [ { id: "year", label: "Abrechnungsjahr", type: "text", default: () => "2025" }, { id: "balance", label: "Saldo (− = Guthaben)", type: "currency", default: () => -148 }, ], build: ({ tenant, unit, fields }) => { const bal = fields.balance || 0; const isCredit = bal < 0; return `Sehr geehrte/r ${tenant?.name || "Mieter/in"}, anbei die Nebenkostenabrechnung für ${unit?.label || "Ihre Wohnung"} für das Jahr ${fields.year}. Ihr Saldo: ${isCredit ? "Guthaben " : "Nachzahlung "}${Math.abs(bal).toLocaleString('de-DE')} € ${isCredit ? "Den Betrag erhalten Sie in den nächsten 14 Tagen auf Ihr Konto erstattet." : "Bitte überweisen Sie den Betrag innerhalb von 30 Tagen."} Eine detaillierte Aufstellung finden Sie im Anhang. Mit freundlichen Grüßen Marie Tessmann`; }, }, { id: "modernization", name: "Modernisierungs-Ankündigung", category: "Vertrag", icon: "🏗", channel: "email", subject: "Modernisierungsmaßnahme — Ankündigung", fields: [ { id: "measure", label: "Maßnahme", type: "text", default: () => "Fassadendämmung" }, { id: "from", label: "Beginn", type: "date", default: () => "2026-08-01" }, { id: "to", label: "Ende", type: "date", default: () => "2026-09-30" }, ], build: ({ tenant, property, fields }) => `Sehr geehrte/r ${tenant?.name || "Mieter/in"}, hiermit kündigen wir gemäß §555c BGB die folgende Modernisierungsmaßnahme im Objekt ${property?.address || ""} an: Maßnahme: ${fields.measure} Zeitraum: ${fmtDateLong(fields.from)} bis ${fmtDateLong(fields.to)} Eine ausführliche Beschreibung der Arbeiten, der voraussichtlichen Belastungen und der zu erwartenden Mietanpassung finden Sie im Anhang. Bei Rückfragen stehe ich Ihnen gern zur Verfügung. Mit freundlichen Grüßen Marie Tessmann`, }, // ───── Hausmeister / Objekt-weite Maßnahmen (oft Rundmail) ───── { id: "water_shutoff", name: "Wasserabstellung", category: "Hausmeister", icon: "💧", channel: "email", bulkFriendly: true, subject: "Wasserabstellung im Haus", fields: [ { id: "date", label: "Datum", type: "date", default: () => "2026-05-14" }, { id: "from", label: "Von", type: "text", default: () => "08:00 Uhr" }, { id: "to", label: "Bis (ca.)", type: "text", default: () => "13:00 Uhr" }, { id: "reason", label: "Grund", type: "text", default: () => "Reparatur Hauptleitung" }, ], build: ({ tenant, property, fields }) => `Hallo ${firstName(tenant?.name)}, aufgrund von Arbeiten (${fields.reason}) wird das Wasser im Objekt ${property?.address || ""} abgestellt: ${fmtDateLong(fields.date)} · ${fields.from} – ${fields.to} Bitte rechtzeitig Wasser abkochen oder bevorraten. Nach Wiederinbetriebnahme empfiehlt sich, kurz die Hähne ablaufen zu lassen. Vielen Dank für Ihr Verständnis. Marie Tessmann`, }, { id: "stairwell_cleaning", name: "Treppenhausreinigung", category: "Hausmeister", icon: "🧹", channel: "email", bulkFriendly: true, subject: "Treppenhausreinigung — Hinweis", fields: [ { id: "weekday", label: "Wochentag", type: "text", default: () => "Donnerstag" }, { id: "time", label: "Uhrzeit", type: "text", default: () => "ab 09:00 Uhr" }, { id: "company", label: "Reinigungsfirma", type: "text", default: () => "Sauberwerk GmbH" }, ], build: ({ tenant, fields }) => `Hallo ${firstName(tenant?.name)}, ab sofort übernimmt ${fields.company} die wöchentliche Treppenhausreinigung — jeden ${fields.weekday}, ${fields.time}. Bitte stellen Sie bis dahin Schuhe, Fußmatten und persönliche Gegenstände aus dem Hausflur. Die Kosten sind Bestandteil der Nebenkostenabrechnung. Viele Grüße Marie Tessmann`, }, { id: "chimney_sweep", name: "Schornsteinfeger-Termin", category: "Hausmeister", icon: "🔥", channel: "email", bulkFriendly: true, subject: "Schornsteinfeger — Pflichttermin", fields: [ { id: "date", label: "Datum", type: "date", default: () => "2026-05-20" }, { id: "window", label: "Zeitfenster", type: "text", default: () => "10:00 – 14:00 Uhr" }, { id: "name", label: "Schornsteinfeger", type: "text", default: () => "Bezirksschornsteinfeger Krause" }, ], build: ({ tenant, fields }) => `Hallo ${firstName(tenant?.name)}, der Schornsteinfeger (${fields.name}) führt die jährliche Pflichtüberprüfung durch: ${fmtDateLong(fields.date)} · ${fields.window} Bitte sorgen Sie dafür, dass die Wohnung zugänglich ist (jemand zuhause oder Schlüssel hinterlegt). Der Termin ist gesetzlich verpflichtend (§1 SchfHwG). Vielen Dank. Marie Tessmann`, }, { id: "smoke_detector", name: "Rauchmelder-Wartung", category: "Hausmeister", icon: "🚨", channel: "email", bulkFriendly: true, subject: "Jährliche Rauchmelder-Wartung", fields: [ { id: "date", label: "Datum", type: "date", default: () => "2026-06-04" }, { id: "window", label: "Zeitfenster", type: "text", default: () => "14:00 – 17:00 Uhr" }, ], build: ({ tenant, fields }) => `Hallo ${firstName(tenant?.name)}, die DIN 14676 schreibt eine jährliche Funktionsprüfung der Rauchmelder vor. Termin in Ihrer Wohnung: ${fmtDateLong(fields.date)} · ${fields.window} Sollten Sie verhindert sein, melden Sie sich bitte für eine Alternative — der Termin ist verpflichtend. Viele Grüße Marie Tessmann`, }, { id: "winter_service", name: "Winterdienst-Hinweis", category: "Hausmeister", icon: "❄", channel: "email", bulkFriendly: true, subject: "Winterdienst & Streupflicht", fields: [ { id: "season", label: "Saison", type: "text", default: () => "November 2026 – März 2027" }, ], build: ({ tenant, fields }) => `Hallo ${firstName(tenant?.name)}, zur Erinnerung — in der Saison ${fields.season} gilt im Objekt der Streu- und Räumplan im Hausflur. Werktags 7:00 – 20:00 Uhr, Sonn- und Feiertags 9:00 – 20:00 Uhr. Bitte halten Sie Ihre Tage ein. Bei Verhinderung organisieren Sie bitte einen Tausch mit einem Nachbarn. Viele Grüße Marie Tessmann`, }, { id: "garbage_calendar", name: "Müllkalender-Hinweis", category: "Hausmeister", icon: "🗑", channel: "email", bulkFriendly: true, subject: "Müllabfuhr-Änderung", fields: [ { id: "wasteType", label: "Abfallart", type: "text", default: () => "Gelbe Tonne" }, { id: "newDay", label: "Neuer Abholtag", type: "text", default: () => "Mittwoch (alle 14 Tage, ungerade KW)" }, { id: "fromDate", label: "Gültig ab", type: "date", default: () => "2026-06-01" }, ], build: ({ tenant, fields }) => `Hallo ${firstName(tenant?.name)}, ab ${fmtDateLong(fields.fromDate)} ändert sich die Abholung der ${fields.wasteType}: ${fields.newDay}. Bitte stellen Sie die Tonne am Vorabend bis 7:00 Uhr bereit. Viele Grüße Marie Tessmann`, }, { id: "house_rules", name: "Hausordnung-Erinnerung", category: "Hausmeister", icon: "📜", channel: "email", bulkFriendly: true, subject: "Erinnerung: Hausordnung", fields: [ { id: "topic", label: "Anlass", type: "text", default: () => "Ruhezeiten und Fahrradstellplätze" }, ], build: ({ tenant, fields }) => `Hallo ${firstName(tenant?.name)}, ich möchte freundlich an die Hausordnung erinnern, insbesondere zum Thema ${fields.topic}. Ruhezeiten gelten werktags 22:00 – 7:00 Uhr und durchgehend an Sonn- und Feiertagen. Fahrräder gehören in den Fahrradkeller, nicht ins Treppenhaus (Brandschutz). Vielen Dank für die Rücksichtnahme — eine gute Nachbarschaft macht das Wohnen für alle angenehmer. Viele Grüße Marie Tessmann`, }, { id: "garden_service", name: "Gartenpflege-Termin", category: "Hausmeister", icon: "🌿", channel: "email", bulkFriendly: true, subject: "Gartenpflege im Haus", fields: [ { id: "date", label: "Datum", type: "date", default: () => "2026-05-22" }, { id: "company", label: "Gärtner", type: "text", default: () => "Grünwerk Stahl" }, ], build: ({ tenant, fields, property }) => `Hallo ${firstName(tenant?.name)}, am ${fmtDateLong(fields.date)} kümmert sich ${fields.company} um den Garten / die Außenanlagen von ${property?.address || ""}. Bitte räumen Sie persönliche Gegenstände (Stühle, Spielzeug, Wäsche) aus den Außenbereichen. Viele Grüße Marie Tessmann`, }, // ───── Mieter-Service ───── { id: "key_lost", name: "Antwort: Schlüsselverlust", category: "Mieter-Service", icon: "🔑", channel: "email", subject: "Schlüsselverlust", fields: [ { id: "newKeyCost", label: "Neue Schlüssel", type: "currency", default: () => 85 }, { id: "lockChange", label: "Zylindertausch (falls nötig)", type: "currency", default: () => 280 }, ], build: ({ tenant, fields }) => `Hallo ${firstName(tenant?.name)}, danke für Ihre umgehende Meldung. Vorgehen: 1. Wir lassen ${(fields.newKeyCost || 0).toLocaleString('de-DE')} € für einen Ersatzschlüssel anfertigen. 2. Falls der Schlüssel nicht aufgefunden wird, tauschen wir aus Sicherheitsgründen den Zylinder (~${(fields.lockChange || 0).toLocaleString('de-DE')} €). 3. Die Kosten gehen zu Ihren Lasten — bitte prüfen Sie, ob Ihre Hausratversicherung den Verlust abdeckt. Bitte bestätigen Sie kurz das Vorgehen. Viele Grüße Marie Tessmann`, }, { id: "owner_change", name: "Eigentümerwechsel", category: "Vertrag", icon: "↻", channel: "email", bulkFriendly: true, subject: "Wichtige Information — Eigentümerwechsel", fields: [ { id: "fromDate", label: "Ab Datum", type: "date", default: () => "2026-07-01" }, { id: "newOwner", label: "Neuer Eigentümer", type: "text", default: () => "Tessmann Immobilien GmbH" }, { id: "newIban", label: "Neue IBAN", type: "text", default: () => "DE12 3456 7890 1234 5678 90" }, ], build: ({ tenant, property, unit, fields }) => `Sehr geehrte/r ${tenant?.name || "Mieter/in"}, hiermit informieren wir Sie, dass das Objekt ${property?.address || ""} ab ${fmtDateLong(fields.fromDate)} in das Eigentum von ${fields.newOwner} übergeht. Ihr Mietvertrag bleibt unverändert bestehen (§566 BGB — „Kauf bricht nicht Miete"). Ab dem genannten Datum überweisen Sie die Miete bitte auf folgendes Konto: ${fields.newOwner} IBAN: ${fields.newIban} Verwendungszweck: ${property?.name || ""}${unit?.label ? `, ${unit.label}` : ""}, Mieter ${tenant?.name || ""} Bitte passen Sie Ihren Dauerauftrag rechtzeitig an. Mit freundlichen Grüßen Marie Tessmann`, }, ]; function firstName(name) { if (!name) return ""; if (name.startsWith("Familie")) return name; if (name.startsWith("Dr.")) return name.split(" ").slice(0, 2).join(" "); return name.split(" ")[0]; } function fmtDateLong(s) { if (!s) return ""; const d = new Date(s); return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }); } // ───── Threads (mit assignees + read receipts) ───── // Leer in Production — Threads entstehen erst durch echte Mieter-Kommunikation. const INITIAL_THREADS = []; const INITIAL_THREADS_DEMO_KEEP = [ { id: "th1", with: "Anna Hofmann", tenantId: "t1", role: "Mieter", propertyId: "p1", unitId: "u1", channel: "email", unread: 2, lastDate: "2026-04-30T14:24", needsReply: true, assignedTo: "o1", messages: [ { id: 1, from: "tenant", date: "2026-04-30T14:22", body: "Guten Tag Frau Tessmann, in der Küche tropft seit gestern der Wasserhahn — könnte jemand vorbeischauen? Mit freundlichen Grüßen, Anna Hofmann" }, { id: 2, from: "tenant", date: "2026-04-30T14:24", body: "Anbei ein Foto.", attachment: "wasserhahn_tropft.jpg" }, ] }, { id: "th2", with: "Café Mira GbR", tenantId: "t12", role: "Pächter", propertyId: "p4", unitId: "u13", channel: "email", unread: 0, lastDate: "2026-04-29T09:14", needsReply: false, assignedTo: "o2", messages: [ { id: 1, from: "tenant", date: "2026-04-28T17:10", body: "Hallo, die Indexmiete-Anpassung ab September — könnten wir das einmal kurz telefonisch besprechen?", receipt: { sentBy: null } }, { id: 2, from: "owner", senderId: "o2", date: "2026-04-29T09:14", body: "Gerne, ich rufe morgen zwischen 10 und 12 an.", receipt: { sent: "2026-04-29T09:14", delivered: "2026-04-29T09:14:08", read: "2026-04-29T09:18" } }, ] }, { id: "th3", with: "Familie Schulz", tenantId: "t3", role: "Mieter", propertyId: "p1", unitId: "u3", channel: "chat", unread: 1, lastDate: "2026-04-30T08:45", needsReply: true, assignedTo: null, messages: [ { id: 1, from: "tenant", date: "2026-04-30T08:45", body: "Können wir am Samstag den Keller besichtigen? Wir wollten alte Kartons aussortieren." }, ] }, { id: "th4", with: "Tobias Lang", tenantId: "t5", role: "Mieter", propertyId: "p2", unitId: "u6", channel: "email", unread: 0, lastDate: "2026-04-25T11:00", needsReply: false, assignedTo: "o1", messages: [ { id: 1, from: "owner", senderId: "o1", date: "2026-04-22T10:00", body: "Modernisierungs-Ankündigung Fassade — siehe Anhang.", attachment: "Modernisierung_Stadtpark7.pdf", receipt: { sent: "2026-04-22T10:00", delivered: "2026-04-22T10:00:15", read: "2026-04-22T10:42" } }, { id: 2, from: "tenant", date: "2026-04-25T11:00", body: "Verstanden, danke für die frühzeitige Info." }, ] }, { id: "th5", with: "Jens Maier", tenantId: "t7", role: "Mieter", propertyId: "p2", unitId: "u8", channel: "email", unread: 1, lastDate: "2026-04-28T16:30", needsReply: true, assignedTo: "o3", messages: [ { id: 1, from: "owner", senderId: "o1", date: "2026-04-12T09:00", body: "Bestätigung Ihrer Kündigung zum 31.07.2026.", receipt: { sent: "2026-04-12T09:00", delivered: "2026-04-12T09:00:12", read: "2026-04-12T11:08" } }, { id: 2, from: "tenant", date: "2026-04-28T16:30", body: "Können wir die Übergabe schon auf den 25.07. vorziehen? Danke." }, ] }, { id: "th6", with: "Lukas Wolf", tenantId: "t11", role: "Mieter", propertyId: "p4", unitId: "u12", channel: "chat", unread: 0, lastDate: "2026-04-26T19:12", needsReply: false, assignedTo: "o1", messages: [ { id: 1, from: "owner", senderId: "o1", date: "2026-04-26T19:00", body: "Mahnung: 980 € Mietrückstand — bitte bis 15.05. überweisen.", receipt: { sent: "2026-04-26T19:00", delivered: "2026-04-26T19:00:04", read: "2026-04-26T19:11" } }, { id: 2, from: "tenant", date: "2026-04-26T19:12", body: "Wird Anfang Mai überwiesen, sorry." }, ] }, ]; // ───── State Store ───── const MessagesContext = React.createContext(null); function MessagesProvider({ children }) { const [threads, setThreads] = React.useState(INITIAL_THREADS); // composer prefill: { threadId?, tenantId?, propertyId?, unitId?, templateId?, ctx? } const [pendingCompose, setPendingCompose] = React.useState(null); const value = { threads, setThreads, pendingCompose, setPendingCompose, appendMessage: (threadId, msg) => { setThreads(prev => prev.map(t => t.id === threadId ? { ...t, messages: [...t.messages, msg], lastDate: msg.date, needsReply: false } : t)); }, markRead: (threadId) => { setThreads(prev => prev.map(t => t.id === threadId ? { ...t, unread: 0 } : t)); }, assignThread: (threadId, ownerId) => { setThreads(prev => prev.map(t => t.id === threadId ? { ...t, assignedTo: ownerId } : t)); }, }; return {children}; } const useMessages = () => React.useContext(MessagesContext); // ───── Notification bell ───── function NotificationBell({ setRoute }) { const ctx = useMessages(); const [open, setOpen] = React.useState(false); const ref = React.useRef(null); const threads = ctx?.threads || INITIAL_THREADS; const unread = threads.reduce((s, t) => s + t.unread, 0); const needsReply = threads.filter(t => t.needsReply).length; React.useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, []); const unreadThreads = threads.filter(t => t.unread > 0).slice(0, 4); const items = [ ...unreadThreads.map(t => ({ type: 'message', threadId: t.id, title: `${t.with} · ${t.messages[t.messages.length - 1].body.slice(0, 60)}`, when: fmtTime(t.lastDate), urgent: t.needsReply, })), { type: 'task', title: '3 Belege fehlen für Lindenstraße', when: 'heute', urgent: true }, { type: 'deadline', title: 'Nebenkostenabrechnung in 18 Tagen', when: 'fällig 19.05.' }, ]; return (
{open && (
Posteingang
{unread} ungelesen · {needsReply} offene Antworten
{items.map((n, i) => ( ))}
)}
); } // ───── Helper: button to start a message from any context ───── function StartMessageButton({ tenantId, propertyId, unitId, maintenanceId, label, className, setRoute, data }) { const ctx = useMessages(); return ( ); } // ───── Read receipt indicator ───── function ReadReceipt({ receipt, channel }) { if (!receipt) return null; const { sent, delivered, read } = receipt; let state = 'sent'; if (read) state = 'read'; else if (delivered) state = 'delivered'; const labels = { sent: 'gesendet', delivered: 'zugestellt', read: channel === 'email' ? 'geöffnet' : 'gelesen', }; const ts = read || delivered || sent; return ( {state === 'sent' && ( )} {state === 'delivered' && ( )} {state === 'read' && ( )} {labels[state]} ); } // ───── Broadcast composer (Rundmail an viele Mieter) ───── function BroadcastModal({ open, onClose, data }) { const ctx = useMessages(); const [step, setStep] = React.useState(1); // 1=template, 2=recipients, 3=preview const [tpl, setTpl] = React.useState(null); const [tmplFields, setTmplFields] = React.useState({}); const [propertyFilter, setPropertyFilter] = React.useState('all'); // 'all' | propertyId const [selected, setSelected] = React.useState(new Set()); // tenantIds const [sending, setSending] = React.useState(false); const [sent, setSent] = React.useState(0); React.useEffect(() => { if (!open) { setStep(1); setTpl(null); setTmplFields({}); setPropertyFilter('all'); setSelected(new Set()); setSending(false); setSent(0); } }, [open]); if (!open) return null; const bulkTemplates = TEMPLATES.filter(t => t.bulkFriendly); const cats = [...new Set(bulkTemplates.map(t => t.category))]; // Build recipient pool: all active tenants const allTenants = Object.values(data.tenants).filter(t => t.status !== 'former'); const filteredTenants = propertyFilter === 'all' ? allTenants : allTenants.filter(t => t.propertyId === propertyFilter); function pickTemplate(t) { setTpl(t); const init = {}; t.fields.forEach(f => { const def = typeof f.default === 'function' ? f.default({}) : f.default; init[f.id] = def; }); setTmplFields(init); // pre-select all tenants setSelected(new Set(allTenants.map(t => t.id))); setStep(2); } function toggle(id) { const s = new Set(selected); s.has(id) ? s.delete(id) : s.add(id); setSelected(s); } function selectAll(yes) { setSelected(yes ? new Set(filteredTenants.map(t => t.id)) : new Set()); } async function doSend() { setSending(true); const recipients = allTenants.filter(t => selected.has(t.id)); let count = 0; for (const tenant of recipients) { const property = data.properties.find(p => p.id === tenant.propertyId); const unit = property?.units.find(u => u.id === tenant.unitId); const body = tpl.build({ tenant, property, unit, fields: tmplFields }); const now = new Date(); const msg = { id: Date.now() + count, from: 'owner', senderId: ME, date: now.toISOString(), body, receipt: { sent: now.toISOString(), delivered: null, read: null }, }; // find existing thread or create new const existing = ctx.threads.find(th => th.tenantId === tenant.id); if (existing) { ctx.appendMessage(existing.id, msg); } else { const newThread = { id: `th_bc_${Date.now()}_${count}`, with: tenant.name, tenantId: tenant.id, role: "Mieter", propertyId: tenant.propertyId, unitId: tenant.unitId, channel: "email", unread: 0, lastDate: now.toISOString(), needsReply: false, assignedTo: ME, messages: [msg], }; ctx.setThreads(prev => [newThread, ...prev]); } count++; setSent(count); // small delay for visual progress await new Promise(r => setTimeout(r, 60)); } // simulate delivery + read receipts after a moment setTimeout(() => { ctx.setThreads(prev => prev.map(th => ({ ...th, messages: th.messages.map(m => m.senderId === ME && m.receipt && !m.receipt.delivered && Date.now() - new Date(m.receipt.sent).getTime() > 1000 ? { ...m, receipt: { ...m.receipt, delivered: new Date().toISOString() } } : m) }))); }, 1500); setTimeout(() => onClose(), 1200); } // Step 1: template if (step === 1) { return (
e.stopPropagation()} style={{ width: 580 }}>
📢 Rundmail verfassen
Schritt 1 von 3 — Vorlage auswählen
{cats.map(cat => (
{cat}
{bulkTemplates.filter(t => t.category === cat).map(t => ( ))}
))}
); } // Step 2: recipients if (step === 2) { const allInFilterSelected = filteredTenants.every(t => selected.has(t.id)) && filteredTenants.length > 0; return (
e.stopPropagation()} style={{ width: 680, maxHeight: '85vh' }}>
📢 Rundmail · {tpl.name}
Schritt 2 von 3 — Empfänger auswählen ({selected.size} ausgewählt)
{filteredTenants.map(t => { const property = data.properties.find(p => p.id === t.propertyId); const unit = property?.units.find(u => u.id === t.unitId); return ( ); })} {filteredTenants.length === 0 && (
Keine Mieter im Filter.
)}
); } // Step 3: preview & send const sampleTenant = allTenants.find(t => selected.has(t.id)); const sampleProperty = sampleTenant && data.properties.find(p => p.id === sampleTenant.propertyId); const sampleUnit = sampleProperty?.units.find(u => u.id === sampleTenant.unitId); const samplePreview = sampleTenant ? tpl.build({ tenant: sampleTenant, property: sampleProperty, unit: sampleUnit, fields: tmplFields }) : ''; return (
e.stopPropagation()} style={{ width: 720, maxHeight: '88vh' }}>
📢 Rundmail · {tpl.name}
Schritt 3 von 3 — Felder anpassen & Vorschau
{tpl.icon} Felder
{tpl.fields.map(f => ( ))}
Empfänger {selected.size}
Kanal E-Mail
Betreff {tpl.subject}
Vorschau für {sampleTenant?.name} 1 von {selected.size}
{samplePreview}
); } // ───── Template picker modal ───── function TemplateModal({ open, onClose, onPick }) { if (!open) return null; const cats = [...new Set(TEMPLATES.map(t => t.category))]; return (
e.stopPropagation()} style={{ width: 540 }}>
Vorlage einfügen
Felder werden automatisch mit Mieter- und Objektdaten gefüllt
{cats.map(cat => (
{cat}
{TEMPLATES.filter(t => t.category === cat).map(t => ( ))}
))}
); } // ───── Template field editor ───── function TemplateFieldEditor({ template, fields, setFields }) { return (
{template.icon} {template.name} Felder anpassen — der Text passt sich automatisch an
{template.fields.map(f => ( ))}
); } // ───── Owner avatar ───── function OwnerAvatar({ ownerId, size = 22 }) { const o = OWNERS.find(x => x.id === ownerId); if (!o) return
?
; return (
{o.initials}
); } // ───── Assign popover ───── function AssignControl({ thread }) { const ctx = useMessages(); const [open, setOpen] = React.useState(false); const ref = React.useRef(null); React.useEffect(() => { const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, []); const owner = OWNERS.find(o => o.id === thread.assignedTo); return (
{open && (
Zuweisen an
{OWNERS.map(o => ( ))}
)}
); } // ───── Messages page ───── function MessagesPage({ data, route, setRoute }) { const ctx = useMessages(); const [activeId, setActiveId] = React.useState(route.threadId || ctx.threads[0]?.id); const [filter, setFilter] = React.useState('all'); const [scope, setScope] = React.useState('all'); // all | mine | unassigned const [draft, setDraft] = React.useState(''); const [tmplOpen, setTmplOpen] = React.useState(false); const [activeTemplate, setActiveTemplate] = React.useState(null); const [tmplFields, setTmplFields] = React.useState({}); const [newOpen, setNewOpen] = React.useState(false); const [broadcastOpen, setBroadcastOpen] = React.useState(false); const newMenuRef = React.useRef(null); React.useEffect(() => { if (!newOpen) return; const h = (e) => { if (newMenuRef.current && !newMenuRef.current.contains(e.target)) setNewOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, [newOpen]); // sync route → activeId React.useEffect(() => { if (route.threadId) setActiveId(route.threadId); }, [route.threadId]); // Handle pending compose from outside React.useEffect(() => { if (ctx.pendingCompose) { const pc = ctx.pendingCompose; if (pc.threadId) setActiveId(pc.threadId); if (pc.templateId) { const tpl = TEMPLATES.find(t => t.id === pc.templateId); if (tpl) { openTemplate(tpl, pc.ctx); } } ctx.setPendingCompose(null); } }, [ctx.pendingCompose]); // mark read on view React.useEffect(() => { if (activeId) { const t = ctx.threads.find(t => t.id === activeId); if (t && t.unread > 0) ctx.markRead(activeId); } }, [activeId]); const visible = ctx.threads.filter(t => { if (scope === 'mine' && t.assignedTo !== ME) return false; if (scope === 'unassigned' && t.assignedTo) return false; if (filter === 'unread') return t.unread > 0; if (filter === 'reply') return t.needsReply; return true; }); const active = ctx.threads.find(t => t.id === activeId) || ctx.threads[0]; const property = active && data.properties.find(p => p.id === active.propertyId); const unit = property?.units.find(u => u.id === active?.unitId); const tenant = active?.tenantId ? data.tenants[active.tenantId] : null; function openTemplate(tpl, extraCtx = {}) { setActiveTemplate(tpl); const ctxData = { tenant, unit, property, ...extraCtx }; const initFields = {}; tpl.fields.forEach(f => { const def = typeof f.default === 'function' ? f.default(ctxData) : f.default; initFields[f.id] = def; }); setTmplFields(initFields); // Build initial body const body = tpl.build({ tenant, unit, property, fields: initFields, ...extraCtx }); setDraft(body); setTmplOpen(false); } // Re-render body when fields change React.useEffect(() => { if (activeTemplate) { const body = activeTemplate.build({ tenant, unit, property, fields: tmplFields }); setDraft(body); } }, [tmplFields]); function send() { if (!draft.trim() || !active) return; const now = new Date(); const newMsg = { id: Date.now(), from: 'owner', senderId: ME, date: now.toISOString(), body: draft.trim(), receipt: { sent: now.toISOString(), delivered: null, read: null }, }; ctx.appendMessage(active.id, newMsg); setDraft(''); setActiveTemplate(null); setTmplFields({}); // simulate delivery + read setTimeout(() => { ctx.setThreads(prev => prev.map(t => t.id === active.id ? { ...t, messages: t.messages.map(m => m.id === newMsg.id ? { ...m, receipt: { ...m.receipt, delivered: new Date().toISOString() } } : m) } : t)); }, 1500); setTimeout(() => { ctx.setThreads(prev => prev.map(t => t.id === active.id ? { ...t, messages: t.messages.map(m => m.id === newMsg.id ? { ...m, receipt: { ...m.receipt, read: new Date().toISOString() } } : m) } : t)); }, 4500); } if (!active) return
Kein Thread.
; const counts = { all: ctx.threads.length, mine: ctx.threads.filter(t => t.assignedTo === ME).length, unassigned: ctx.threads.filter(t => !t.assignedTo).length, }; // Empty state — no messages yet if (ctx.threads.length === 0) { return (

Nachrichten

Posteingang für Mieter-Kommunikation
Noch keine Nachrichten
Sobald du E-Mail/SMS-Anbindung verbindest und Mieter anschreibst, erscheinen die Threads hier. Modul ist in Entwicklung.
); } return (

Nachrichten

Geteilter Posteingang · {OWNERS.map(o => o.name).join(' · ')}
{newOpen && (
)}
{[ { v: 'all', l: 'Alle' }, { v: 'unread', l: 'Ungelesen' }, { v: 'reply', l: 'Antwort offen' }, ].map(f => ( ))}
{visible.length === 0 &&
Keine Nachrichten in dieser Ansicht.
} {visible.map(t => { const last = t.messages[t.messages.length - 1]; const prop = data.properties.find(p => p.id === t.propertyId); return ( ); })}
{active.with}
{active.role} · {property?.name}{unit ? ` · ${unit.label}` : ''} · {active.channel === 'email' ? 'E-Mail' : 'Chat'}
{active.messages.length === 0 && (
Neuer Entwurf — verfassen Sie unten Ihre erste Nachricht {tenant &&
📧 {tenant.email}
}
)} {active.messages.map(m => (
{m.from === 'owner' && m.senderId && m.senderId !== ME && (
{OWNERS.find(o => o.id === m.senderId)?.name}
)}
{m.body}
{m.attachment &&
📎 {m.attachment}
}
{fmtTime(m.date)} {m.from === 'owner' && }
))}
{activeTemplate && ( )}
{activeTemplate && ( )} Versand via {active.channel === 'email' ? 'E-Mail (SMTP)' : 'Chat-API'}