1/** 2 * Das Kunden CRM AUSSERHALB des CRM - globale Suche, Glocke und Startseite. 3 * 4 * REINE RECHNUNG, KEIN DOM, KEIN NETZ: main.js und die Startseite reichen den 5 * Bestand herein, den sie ohnehin halten (den laufenden CRM-Dienst oder die 6 * Positivliste des Server-Tors). Hier steht nur, WAS daraus wird - damit die 7 * Regel in `npm test` mitlaeuft und nicht erst im angemeldeten Browser. 8 * 9 * DIE FACHREGELN WERDEN NICHT NACHGEBAUT: Gesucht wird mit `sucheKunden`, 10 * benannt mit `anzeigeName`, die Termine kommen aus der Wiedervorlage. Eine 11 * zweite Fassung liefe beim naechsten Feinschliff im CRM auseinander - und 12 * dann faende die Kopfzeile einen Kunden nicht, den die Liste daneben zeigt. 13 * 14 * NICHT IM STARTPAKET: main.js holt dieses Modul erst beim ersten Bedarf 15 * (`import()`), wie einen Bildschirm. Es zieht die Such- und 16 * Wiedervorlagebausteine des CRM nach sich; in `glockenQuellen.js`, das 17 * statisch geladen wird, waeren sie bei jeder Anmeldung mit unterwegs. 18 */ 19 20import { sucheKunden } from '../../features/kundencrm/domain/suche.js'; 21import { anzeigeName } from '../../features/kundencrm/domain/namen.js'; 22import { ANLASS_ARTEN, terminText } from '../../features/kundencrm/domain/wiedervorlage.js'; 23import { ART, meldung } from '../../features/notifications/domain/Benachrichtigung.js'; 24import { tagesDatum } from '../../core/utils/date.js'; 25 26const text = (wert) => String(wert ?? '').trim(); 27 28/** Hoechstens so viele Kunden je Suche - mehr verdraengt die anderen Quellen. */ 29export const MAX_KUNDEN_TREFFER = 8; 30 31/** Ab so vielen Zeichen wird gesucht - dieselbe Grenze wie die Kopfzeilensuche. */ 32export const MIN_SUCHZEICHEN = 2; 33 34/** Die Adresse einer Kundenakte, wahlweise mit Reiter. Die Kennung ist kein Name. */ 35export function akteAdresse(kundeId, reiter = '') { 36 const id = encodeURIComponent(text(kundeId)); 37 return reiter ? `#kundencrm/kunde/${id}/${encodeURIComponent(reiter)}` : `#kundencrm/kunde/${id}`; 38} 39 40/** 41 * Die Kundentreffer der globalen Suche. 42 * 43 * DER BESTAND IST BEREITS GEFILTERT: Wer ihn hereinreicht, hat entweder 44 * `sichtbareKunden` angewandt oder die Positivliste des Servers geholt, die 45 * nur die eigene Kette enthaelt. Diese Funktion ist keine zweite 46 * Zugriffsgrenze - zwei Grenzen liefen auseinander. 47 * 48 * @param {Array<object>} kunden 49 * @param {string} anfrage 50 * @param {object} [umfeld] 51 * @param {Map<string, Array<object>>} [umfeld.vertraegeJeKunde] damit auch die 52 * Vertragsnummer trifft (nur, wenn der Dienst die Vertraege haelt) 53 * @param {(kunde: object) => string} [umfeld.betreuerVon] Name des Betreuers 54 * @param {number} [umfeld.max] 55 * @returns {Array<{id: string, titel: string, untertitel: string, ziel: string}>} 56 */ 57export function kundenSuchtreffer(kunden, anfrage, umfeld = {}) { 58 const { vertraegeJeKunde = null, betreuerVon = (k) => k?.betreuerName, max = MAX_KUNDEN_TREFFER } = umfeld; 59 // `sucheKunden` liefert bei leerer Anfrage ALLE Kunden - fuer die Liste im 60 // CRM richtig, fuer die Kopfzeile eine Aufzaehlung des Bestands. 61 if (text(anfrage).length < MIN_SUCHZEICHEN) return []; 62 63 return sucheKunden(Array.isArray(kunden) ? kunden : [], anfrage, 64 vertraegeJeKunde ? { vertraegeJeKunde } : {}) 65 .filter((k) => text(k?.id)) 66 .slice(0, max) 67 .map((k) => ({ 68 id: text(k.id), 69 titel: anzeigeName(k), 70 untertitel: [text(k.ort), text(betreuerVon(k))].filter(Boolean).join(' · ') || 'Kundenakte', 71 ziel: akteAdresse(k.id), 72 })); 73} 74 75/** Hoechstens so viele Anlaesse auf der Startseite. */ 76export const MAX_CRM_SCHRITTE = 3; 77 78/** 79 * Wie weit die Startseite nach vorn sieht. 80 * 81 * EINE WOCHE, NICHT DIE 60 TAGE DER WIEDERVORLAGE: âIhre naechsten Schritte" 82 * ist das, was jetzt zu tun ist. Eine Finanzierung, die in sieben Wochen 83 * auslaeuft, gehoert in die Wiedervorlage; auf der Startseite stuende sie 84 * dauerhaft und machte die Liste zu einer, die man wegliest. 85 */ 86export const CRM_SCHRITTE_VORLAUF_TAGE = 7; 87 88/** 0 = ueberfaellig, 1 = heute, 2 = in den naechsten Tagen. */ 89function dringlichkeit(anlass) { 90 if (anlass.ueberfaellig) return 0; 91 return Number(anlass.tage) === 0 ? 1 : 2; 92} 93 94/** 95 * Die CRM-Anlaesse fuer âIhre naechsten Schritte". 96 * 97 * UEBERFAELLIG ZUERST, DANN HEUTE, DANN DIE NAECHSTEN TAGE - innerhalb jeder 98 * Gruppe in der Reihenfolge der Wiedervorlage (nach Termin), damit Startseite 99 * und Reiter âAnstehend" dieselbe Liste von oben nach unten zeigen. 100 * 101 * @param {Array<object>} anlaesse Ergebnis von `anstehendeAnlaesse` 102 * @param {{grenze?: number, vorlaufTage?: number}} [einstellungen] 103 * @returns {{schritte: Array<{text: string, ziel: string, knopf: string, ueberfaellig: boolean}>, 104 * weitere: number}} 105 */ 106export function crmSchritte(anlaesse, einstellungen = {}) { 107 const { grenze = MAX_CRM_SCHRITTE, vorlaufTage = CRM_SCHRITTE_VORLAUF_TAGE } = einstellungen; 108 const passend = (Array.isArray(anlaesse) ? anlaesse : []) 109 .filter((a) => text(a?.kundeId)) 110 .filter((a) => a.ueberfaellig || (Number(a.tage) >= 0 && Number(a.tage) <= vorlaufTage)) 111 .map((a, stelle) => ({ a, stelle })) 112 .sort((x, y) => dringlichkeit(x.a) - dringlichkeit(y.a) || x.stelle - y.stelle) 113 .map(({ a }) => a); 114 115 const schritte = passend.slice(0, grenze).map((a) => { 116 // âGeburtstag - heute" traegt den Tag schon im Titel; hinten steht er 117 // ohnehin - zweimal âheute" in einer Zeile liest sich wie ein Fehler. 118 const anlass = a.art === 'geburtstag' ? 'Geburtstag' : text(a.titel); 119 return { 120 text: `${anlass}: ${text(a.kundeName) || 'Ohne Namen'} (${terminText(a)})`, 121 ziel: akteAdresse(a.kundeId, a.reiter), 122 knopf: 'Zur Akte', 123 ueberfaellig: !!a.ueberfaellig, 124 }; 125 }); 126 127 return { schritte, weitere: Math.max(0, passend.length - schritte.length) }; 128} 129 130/** Bis zu so vielen Tagen vor dem Termin meldet die Glocke eine Kuendigungsfrist. */ 131export const GLOCKE_FRIST_TAGE = 3; 132 133/** 134 * Das Dringende aus der Wiedervorlage fuer die Glocke. 135 * 136 * NUR DREI DINGE, nicht die ganze Wiedervorlage: ein Geburtstag HEUTE, eine 137 * Kuendigungsfrist eines Maklermandats in hoechstens drei Tagen und das 138 * Ueberfaellige. Alles andere steht im Reiter âAnstehend" - eine Glocke, die 139 * jede Vertragsfrist der naechsten 60 Tage meldet, wird weggeklickt und 140 * traegt dann auch dort nicht mehr, wo es darauf ankommt. 141 * 142 * DER ZEITPUNKT IST DER BEGINN DES HEUTIGEN TAGES, und das ist dieselbe 143 * Mechanik wie beim Wochenmontag der Mittagskasse: Die Glocke zeigt, was 144 * nach dem letzten âAlle als gelesen" liegt. Mit Mitternacht als Zeitpunkt 145 * erscheint jede dieser Meldungen EINMAL je Tag - nicht bei jedem Abruf neu 146 * (ein âjetzt" waere eine Meldung, die nie verschwindet) und nicht nie wieder 147 * (der Termin selbst laege fuer eine morgige Frist in der Zukunft und fuer 148 * Ueberfaelliges vor dem letzten Blick). Die `kennung` (Anlass + Tag) sagt, 149 * welche Sache gemeint ist; zwei Abrufe am selben Tag liefern dieselbe. 150 * 151 * UEBERFAELLIGES WIRD ZUSAMMENGEFASST, sobald es mehr als ein Fall ist: Im 152 * Bestand stehen Dutzende Vertraege mit vergangenem Ende auf âaktiv". Je Fall 153 * eine Zeile fuellte die Glocke und verdraengte die Meldungen der Kollegen. 154 * Die Sammelzeile fuehrt in die Wiedervorlage, wo Ueberfaelliges oben steht. 155 * 156 * ERST DIE BILLIGE FRAGE, DANN DAS LADEN - wie bei der Mittagskasse (L13-8): 157 * Hat jemand heute schon âAlle als gelesen" gedrueckt, kann nichts Neues 158 * kommen, und der CRM-Dienst wird dafuer gar nicht erst gestartet. 159 * 160 * @param {object} p 161 * @param {Date} p.seit der letzte Blick 162 * @param {Date} [p.jetzt] 163 * @param {() => Promise<Array<object>|null>} p.lade die Anlaesse (`anstehendeAnlaesse`) 164 * @returns {Promise<Array<object>>} Meldungen mit `ziel` (Adresse) statt `oeffne` 165 */ 166export async function kundencrmMeldungen({ seit, jetzt = new Date(), lade }) { 167 const tagesbeginn = new Date(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate()); 168 const grenze = seit instanceof Date ? seit : new Date(seit); 169 if (!(tagesbeginn > grenze)) return []; 170 171 const anlaesse = await lade();
172 if (!Array.isArray(anlaesse) || !anlaesse.length) return []; 173 174 const zeitpunkt = tagesbeginn.toISOString(); 175 // Ortszeit, nicht UTC: kurz nach Mitternacht waere es sonst noch gestern. 176 const tag = tagesDatum(tagesbeginn); 177 const name = (a) => text(a.kundeName) || 'Ohne Namen'; 178 const einzeln = (a, titel, zeile) => ({ 179 ...meldung({ 180 art: ART.KUNDE, 181 titel, 182 text: zeile, 183 zeitpunkt, 184 kennung: `${a.art}:${a.kundeId}:${a.termin}:${tag}`, 185 }), 186 ziel: akteAdresse(a.kundeId, a.reiter), 187 }); 188 189 const meldungen = []; 190 for (const a of anlaesse) { 191 if (!text(a?.kundeId) || a.ueberfaellig) continue; 192 if (a.art === 'geburtstag' && Number(a.tage) === 0) { 193 meldungen.push(einzeln(a, `Geburtstag heute: ${name(a)}`, text(a.zeile))); 194 } else if (a.art === 'mandat' && Number(a.tage) >= 0 && Number(a.tage) <= GLOCKE_FRIST_TAGE) { 195 meldungen.push(einzeln(a, `Kündigungsfrist ${terminText(a)}: ${name(a)}`, text(a.zeile))); 196 } 197 } 198 199 const ueberfaellig = anlaesse.filter((a) => a?.ueberfaellig && text(a.kundeId)); 200 if (ueberfaellig.length === 1) { 201 const [a] = ueberfaellig;
202 meldungen.push(einzeln(a, `${text(a.titel)} â ${terminText(a)}`, 203 [name(a), text(a.zeile)].filter(Boolean).join(' · '))); 204 } else if (ueberfaellig.length > 1) { 205 // âVertragsenden: 3" statt â3 Vertragsenden": Die Bezeichnungen der 206 // Wiedervorlage stehen in der Mehrzahl, und eine Eins davor laese sich 207 // wie ein Fehler. 208 const jeArt = ANLASS_ARTEN 209 .map((art) => [art.label, ueberfaellig.filter((a) => a.art === art.schluessel).length]) 210 .filter(([, n]) => n > 0) 211 .map(([label, n]) => `${label}: ${n}`); 212 meldungen.push({ 213 ...meldung({ 214 art: ART.KUNDE, 215 titel: `${ueberfaellig.length} überfällige Anlässe in der Wiedervorlage`, 216 text: jeArt.join(' · '), 217 zeitpunkt, 218 kennung: `ueberfaellig:${tag}`, 219 }), 220 ziel: '#kundencrm/anstehend', 221 }); 222 } 223 224 return meldungen; 225}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.