PageSourceSearch

https://trialog-8a95b.web.app/src/shared/crm/kundenHinweise.js

js trialog-8a95b.web.app collected 2026-10-03 12:39:05 UTC 10,055 bytes, 225 lines download raw bytes

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.