Compare commits

...
12 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 f11fd0bb25 Funktionen: News im Abo-Modell in Einrichtung ohne "Aktivieren (ohne Zahlung)" (Abschlussprüfung W2)
Das Portal antwortet darauf immer mit 409 bereits_eingeschaltet. Erkannt wird das Abo-Modell am Marker unlockedBy (abo, projekt) der Funktion; bei fehlendem Ticket steht "Ticket nachlegen" an der Stelle.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:17:35 +00:00
ClaudeandClaude Sonnet 5.5 8685411a93 Stundenkonto: Vorschuss bei beendetem Abo als "verfällt, wird nicht berechnet" anzeigen (Abschlussprüfung W1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:16:45 +00:00
ClaudeandClaude Sonnet 5.5 1742fb254d Funktionen: Warnung der Aufbauphase, Abo-Beginn mit Datum, Texte für abgeschaltet und bereits eingeschaltet
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:05:46 +00:00
ClaudeandClaude Sonnet 5.5 06166221ec Funktionen: Abrechnung der Einrichtung wählen, Abo-Fehler anzeigen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:55:05 +00:00
ClaudeandClaude Sonnet 5.5 8f75276c54 Funktionen: Preis entfernt, Abrechnung und Abo-Fehlertext als reine Logik
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:48:34 +00:00
ClaudeandClaude Sonnet 5.5 dce7c3e53a Stundenkonto und Vorschuss am Einrichtungs-Ticket und beim Kunden
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:45:39 +00:00
ClaudeandClaude Sonnet 5.5 f0d39f2eee Arbeitsblätter: Abo-Vorgabe bei Einrichtungs-Tickets
Bei Tickets mit einrichtungAbrechnung 'abo' steht "Vom Abo-Stundenkonto abziehen" im Dialog von Anfang an auf an (auch wenn der Abo-Status erst nach dem Öffnen ankommt), beim Nachtragen der Zeit eines Git-Blatts wird countsToPlan beim ersten Eintragen gesetzt. 'projekt', Kommentare und Kunden ohne Wartungs-Abo bekommen keine Vorgabe; ein von Hand abgehakter Haken bleibt aus.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:36:34 +00:00
ClaudeandClaude Sonnet 5.5 5e59ad44bd Kundenliste: Schalter Kundenart fehlt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:32:04 +00:00
ClaudeandClaude Sonnet 5.5 58bb956645 Lead zum festen Kunden nur mit Kundenart
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:28:20 +00:00
ClaudeandClaude Sonnet 5.5 463aaf1066 Kunden: Kundenart ist beim Anlegen Pflicht
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:23:28 +00:00
ClaudeandClaude Sonnet 5.5 cbf4b265e0 Leads: Kundenart ist beim Anlegen Pflicht
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:18:10 +00:00
ClaudeandClaude Sonnet 5.5 45b2b145b0 Kundenart: Prüfung, Texte und Segment-Schalter
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 13:11:52 +00:00
27 changed files with 807 additions and 49 deletions

No files matched your search

+12 -4
View File
@@ -7,6 +7,7 @@ import { Button, DateTimeInput, Dialog, DurationSelect, Field } from './ui'
import { roundUpToStep, isValidStep } from '../lib/timeSteps'
import { validateSupporters } from '../lib/support'
import SupportersEditor from './SupportersEditor'
import { countsToPlanWert } from '../lib/planVorgabe'
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
@@ -31,7 +32,7 @@ const initialForm = (workorder, newStatus) => ({
endTime: '',
details: '',
isComment: false,
countsToPlan: false,
countsToPlan: null, // null = unberührt: dann gilt die Abo-Vorgabe des Tickets (planVorgabe.js)
supporters: []
})
@@ -50,6 +51,13 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
: (workorder?.status || 'Open')
const [formData, setFormData] = useState(() => initialForm(workorder, defaultStatus))
// Der Abo-Status kommt oft erst nach dem Öffnen: der Wert wird bei jedem Rendern aus Handwahl und Vorgabe berechnet
const countsToPlan = countsToPlanWert({
gewaehlt: formData.countsToPlan,
ticket: workorder,
aboHoursAllowed,
isComment: formData.isComment
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -130,7 +138,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
endTime: timeToWoms(formData.endTime),
details: formData.details,
isComment: formData.isComment,
countsToPlan: aboHoursAllowed && !formData.isComment && formData.countsToPlan,
countsToPlan,
employeeShort: user?.prefs?.shortCode || '', // Aus User-Preferences
supporters
}
@@ -193,7 +201,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
<Field
label={isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}
htmlFor="worksheet-new-status"
hint={isAcquisition ? '"Zugesagt" macht den Lead automatisch zum festen Kunden.' : undefined}
hint={isAcquisition ? '„Zugesagt“ macht den Lead zum festen Kunden, sobald seine Kundenart festgelegt ist.' : undefined}
>
<select
id="worksheet-new-status"
@@ -222,7 +230,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
<label className="check" title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
<input
type="checkbox"
checked={formData.countsToPlan && !formData.isComment}
checked={countsToPlan}
disabled={formData.isComment}
onChange={(e) => handleChange('countsToPlan', e.target.checked)}
/>
+21
View File
@@ -0,0 +1,21 @@
import { KUNDENART_AUSWAHL } from '../lib/kundenFelder'
/** Segment-Schalter für die Kundenart (V9): zwei Werte, nie eine Vorauswahl; der Aufrufer prüft mit kundenartPruefen. */
export default function KundenartAuswahl({ value, onChange, disabled = false }) {
return (
<div className="seg" role="group" aria-label="Kundenart">
{KUNDENART_AUSWAHL.map((o) => (
<button
key={o.value}
type="button"
className={value === o.value ? 'on' : ''}
aria-pressed={value === o.value}
disabled={disabled}
onClick={() => onChange(o.value)}
>
{o.label}
</button>
))}
</div>
)
}
+6 -1
View File
@@ -3,6 +3,7 @@ import { Card, Badge } from '../ui'
import { getCustomerAboStatus } from '../../lib/teamApi'
import { fmtDate, productLabelOf } from '../../lib/format'
import { storageLabel } from '../../lib/storageFormat'
import StundenkontoLeiste from './StundenkontoLeiste'
// Abo-Status eines Kunden (Reiter "Abo & Angebote"): Appwrite-Spiegel des Stripe-Abos, fuer alle Mitarbeiter
export default function AboStatusCard({ customerId }) {
@@ -19,13 +20,16 @@ export default function AboStatusCard({ customerId }) {
return () => { alive = false }
}, [customerId])
// Nur ein aktives Wartungs-Abo hat ein Stundenkonto (das Hosting-Abo nicht)
const mitKonto = Boolean(abo && abo.productKey === 'wartung' && abo.active)
return (
<Card title="Abo">
{error && <div className="msg err" role="alert">{error}</div>}
{!abo && !error && <div className="loading">Wird geladen …</div>}
{abo && !abo.status && <p className="muted">kein Abo</p>}
{abo && abo.status && (
<dl className="kv">
<dl className={mitKonto ? 'kv mb-2' : 'kv'}>
<dt>Abo</dt>
<dd className="flex items-center gap-2 wrap">
{productLabelOf(abo.productKey)}
@@ -37,6 +41,7 @@ export default function AboStatusCard({ customerId }) {
<dt>Zusatzspeicher</dt><dd>{storageLabel(abo.storageUnits)}</dd>
</dl>
)}
{mitKonto && <StundenkontoLeiste customerId={customerId} />}
</Card>
)
}
+26 -5
View File
@@ -4,9 +4,10 @@ import { Card, Badge, Button, EmptyState } from '../ui'
import { copyText } from '../CopyableCredential'
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage, getUnterkunft, entscheideUnterkunftAnfrage } from '../../lib/teamApi'
import {
FUNKTIONEN, ZUSTAND, AKTION_LABEL, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText,
FUNKTIONEN, ZUSTAND, AKTION_LABEL, funktionOf, aktionen, aboModellFunktion, mergeFunktion, siteKeyOf, abgeschaltetText,
hindernisText, panelModell, anleitungOffen, textformNoetig, textformAnsicht, textformBereit, speicherStatus,
anfrageZeilen, anfrageStatus, stripeText, stripeTon, shopHatEintrag, begruendungOk, BEGRUENDUNG_MAX, entscheidungStatus,
ABRECHNUNG_OPTIONEN, ABRECHNUNG_HINWEIS, fragtAbrechnung, abrechnungBereit, featureFehlerText,
} from '../../lib/funktionenView'
import { FUNKTION_CHANNELS, channelLabel } from '../../lib/analyseView'
import { berlinYmd, fmtDateTime } from '../../lib/format'
@@ -264,6 +265,22 @@ function UnterkunftPanel({ customerId, eintrag, onDecided }) {
)
}
// Wahl beim Aktivieren, wohin die Einrichtungszeit geht (V9): Segment-Schalter ohne Vorauswahl, dahinter die Erklaerung (ohne Preis)
function AbrechnungWahl({ value, onChange, disabled }) {
return (
<>
<div className="seg" role="group" aria-label="Abrechnung der Einrichtung">
{ABRECHNUNG_OPTIONEN.map((o) => (
<button key={o.value} type="button" className={value === o.value ? 'on' : ''} aria-pressed={value === o.value} disabled={disabled} onClick={() => onChange(o.value)}>
{o.label}
</button>
))}
</div>
<span className="muted small">{ABRECHNUNG_HINWEIS}</span>
</>
)
}
const LEERE_TEXTFORM = { confirmed: false, date: '', channel: '' }
export default function CustomerFunktionen({ customerId }) {
@@ -273,6 +290,7 @@ export default function CustomerFunktionen({ customerId }) {
const [error, setError] = useState(null)
const [confirm, setConfirm] = useState(null) // { projectId, key, action }
const [note, setNote] = useState('')
const [abrechnung, setAbrechnung] = useState('') // Wahl beim Aktivieren: '' (noch keine) | 'abo' | 'projekt'; nie vorbelegt
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState(null) // { ok, text }
const [panel, setPanel] = useState({}) // projectId -> true/false (Knopf "Anleitung"); ohne Eintrag entscheidet die Adresse (?anleitung=<projectId>)
@@ -309,9 +327,11 @@ export default function CustomerFunktionen({ customerId }) {
setStatus(null)
try {
const mitTextform = textformNoetig(key, action, felder[key])
const mitAbrechnung = fragtAbrechnung(key, action, funktionOf(projects?.find((p) => p.projectId === projectId), key))
const antwort = await setFeature(customerId, key, {
projectId, action, note: note.trim(),
...(mitTextform ? { textformDate: textform.date, textformChannel: textform.channel } : {}),
...(mitAbrechnung ? { abrechnung } : {}),
})
// Die Antwort enthaelt das ganze Projekt; uebernommen wird nur die geaenderte Funktion
const updated = antwort?.projects?.[0]
@@ -323,7 +343,7 @@ export default function CustomerFunktionen({ customerId }) {
setStatus(key === 'zeiten' && action === 'disable' ? abschaltMeldung(antwort?.ersatzTicket) : speicherStatus(antwort, key))
} catch (err) {
setConfirm(null)
setStatus({ ok: false, text: err.message })
setStatus({ ok: false, text: featureFehlerText(err, key) })
} finally {
setBusy(false)
// Version und Hindernis der Anleitung haengen vom Zustand ab: Panel dieser Zeile neu laden
@@ -334,7 +354,7 @@ export default function CustomerFunktionen({ customerId }) {
}
const teamGespeichert = (projectId, antwort) => setProjects((list) => mergeTeam(list, projectId, antwort))
const ask = (projectId, key, action) => { setNote(''); setTextform(LEERE_TEXTFORM); setConfirm({ projectId, key, action }) }
const ask = (projectId, key, action) => { setNote(''); setTextform(LEERE_TEXTFORM); setAbrechnung(''); setConfirm({ projectId, key, action }) }
const heute = berlinYmd(new Date())
const panelOffen = (projectId) => (Object.prototype.hasOwnProperty.call(panel, projectId) ? panel[projectId] : anleitungOffen(window.location.search, projectId))
const togglePanel = (projectId) => setPanel((p) => ({ ...p, [projectId]: !panelOffen(projectId) }))
@@ -398,7 +418,7 @@ export default function CustomerFunktionen({ customerId }) {
<td>
{!isConfirming && (
<div className="funktionen-actions">
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing, hindernis: feature.hindernis }).map((action) => (
{aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing, hindernis: feature.hindernis, aboModell: aboModellFunktion(feature) }).map((action) => (
<button key={action} type="button" className="link" onClick={() => ask(p.projectId, f.key, action)}>
{AKTION_LABEL[action]}
</button>
@@ -417,8 +437,9 @@ export default function CustomerFunktionen({ customerId }) {
<td colSpan={4}>
<div className="flex items-center gap-2 wrap">
<span>{f.bestaetigen[confirm.action]}</span>
{fragtAbrechnung(f.key, confirm.action, feature) && <AbrechnungWahl value={abrechnung} onChange={setAbrechnung} disabled={busy} />}
<input className="input funktionen-note" placeholder="Notiz (optional)" maxLength={120} aria-label="Notiz (optional)" value={note} onChange={(e) => setNote(e.target.value)} />
<Button type="button" variant="primary" onClick={save} disabled={busy || textformFehlt}>{busy ? 'Speichert …' : 'Ja'}</Button>
<Button type="button" variant="primary" onClick={save} disabled={busy || textformFehlt || !abrechnungBereit({ key: f.key, action: confirm.action, feature, abrechnung })}>{busy ? 'Speichert …' : 'Ja'}</Button>
<Button type="button" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
</div>
</td>
@@ -0,0 +1,18 @@
import { Stat } from '../ui'
import { useStundenkonto } from '../../hooks/useStundenkonto'
import { stundenkontoAnsicht } from '../../lib/stundenkontoView'
// Stundenkonto eines Kunden als Kennzahlen: Guthaben, Vorschuss für die Einrichtung, Mehraufwand, nächster Verfall.
// Hosting-Abo und fehlendes Abo bekommen nur den Hinweis. Ohne brauchbare Antwort (Fehler, altes Portal) steht nichts da.
export default function StundenkontoLeiste({ customerId, reloadKey = '' }) {
const { antwort, loading } = useStundenkonto(customerId, { reloadKey })
if (loading) return <p className="muted">Wird geladen …</p>
const ansicht = stundenkontoAnsicht(antwort)
if (!ansicht) return null
if (ansicht.art !== 'konto') return <p className="muted">{ansicht.hinweis}</p>
return (
<div className="stats">
{ansicht.eintraege.map((e) => <Stat key={e.key} label={e.label} value={e.wert} sub={e.sub} tone={e.ton} />)}
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react'
import { getCustomerStundenkonto } from '../lib/teamApi'
// { antwort, loading }: Stundenkonto eines Kunden (V8), neu geladen, wenn sich customerId oder reloadKey ändern
// (z. B. nach einem neuen Arbeitsblatt). Fehler, auch 404 eines älteren Portals, bleiben still: antwort ist dann null
// und nur die Konsole bekommt eine Warnung. Eine Antwort für einen früheren Schlüssel wird verworfen und nie gezeigt.
// enabled=false: noch nichts laden.
export function useStundenkonto(customerId, { enabled = true, reloadKey = '' } = {}) {
const key = `${customerId}|${reloadKey}`
const [state, setState] = useState({ key: null, antwort: null })
useEffect(() => {
if (!customerId || !enabled) return undefined
let cancelled = false
getCustomerStundenkonto(customerId)
.then((antwort) => { if (!cancelled) setState({ key, antwort }) })
.catch((err) => {
console.warn('Stundenkonto des Kunden konnte nicht geladen werden:', err?.message || err)
if (!cancelled) setState({ key, antwort: null })
})
return () => { cancelled = true }
}, [customerId, enabled, key])
const aktuell = state.key === key
return {
antwort: aktuell ? state.antwort : null,
loading: Boolean(customerId) && enabled && !aktuell,
}
}
+4 -3
View File
@@ -228,15 +228,16 @@ export function useWorksheets(woid = null) {
/**
* Aendert nachtraeglich Beginn, Ende und Arbeitszeit - nur fuer den Ersteller
* (employee = eigener Eintrag aus "employees", fuer Git-Pushes)
* (employee = eigener Eintrag aus "employees", fuer Git-Pushes).
* extra: weitere Felder fuer dasselbe Update (z. B. countsToPlan aus der Abo-Vorgabe)
*/
const updateWorksheetTimes = async (worksheet, times, currentUser, employee) => {
const updateWorksheetTimes = async (worksheet, times, currentUser, employee, extra = {}) => {
if (!canEditWorksheetTimes(worksheet, currentUser, employee)) {
return { success: false, error: 'Nur wer das Arbeitsblatt erstellt hat, kann die Zeiten ändern.' }
}
const { startDate, startTime, endDate, endTime, totalTime } = times
if (!isValidStep(totalTime)) return { success: false, error: STEP_ERROR }
const data = { startDate, startTime, endDate, endTime, totalTime }
const data = { startDate, startTime, endDate, endTime, totalTime, ...extra }
// Unterstuetzung nur schreiben, wenn sie sich geaendert hat. Hat inzwischen jemand anderes
// (z. B. ueber "Ich habe unterstuetzt") etwas geaendert, nicht still ueberschreiben.
if (times.supporters) {
+19 -6
View File
@@ -1,5 +1,7 @@
// Reine Funktionen der Kundenliste und der Kundenseite: Stufe, Filter, Zähler und Abzeichen.
// Ohne Importe, damit Liste, Zähler und Tests dieselbe Logik benutzen.
// Frei von React und Appwrite (nur der Kundenart-Kern), damit Liste, Zähler und Tests dieselbe Logik benutzen.
import { kundenartGesetzt } from './kundenFelder'
export function customerStage(c) {
const s = (c?.customerStatus || '').toLowerCase()
@@ -22,11 +24,21 @@ export const CUSTOMER_TABS = [
{ id: 'all', label: 'Alle' },
]
/** Fehlt die Kundenart? Alles außer genau 'unternehmen' oder 'privat' gilt als nicht gesetzt (V4). */
export function kundenartFehlt(customer) {
return !kundenartGesetzt(customer?.kundenart)
}
/** Zahl für den Schalter „Kundenart fehlt“: nicht archivierte Leads und feste Kunden ohne Kundenart (Abgesagte zählen nicht). */
export function kundenartFehltAnzahl(customers) {
return (customers || []).filter((c) => !isArchived(c) && customerStage(c) !== 'lost' && kundenartFehlt(c)).length
}
/**
* Filtert die Kundenliste nach Reiter und Suchtext. Die Reihenfolge der Eingabe bleibt.
* Archivierte erscheinen nur im Reiter `archived`.
* Archivierte erscheinen nur im Reiter `archived`. Mit `nurKundenartFehlt` bleiben nur Einträge ohne Kundenart.
*/
export function filterCustomers(customers, { stage = 'customer', search = '' } = {}) {
export function filterCustomers(customers, { stage = 'customer', search = '', nurKundenartFehlt = false } = {}) {
const s = String(search || '').toLowerCase()
const matchesStage = (c) => {
const archived = isArchived(c)
@@ -38,15 +50,16 @@ export function filterCustomers(customers, { stage = 'customer', search = '' } =
}
return (customers || []).filter((c) => {
if (!matchesStage(c)) return false
if (nurKundenartFehlt && !kundenartFehlt(c)) return false
if (!s) return true
return `${c.name || ''} ${c.code || ''} ${c.location || ''} ${c.email || ''} ${c.contactName || ''} ${c.phone || ''}`.toLowerCase().includes(s)
})
}
/** Zähler je Reiter, berechnet mit derselben Filterfunktion wie die Liste (beachtet den Suchtext). */
export function stageCounts(customers, search = '') {
/** Zähler je Reiter, berechnet mit derselben Filterfunktion wie die Liste (beachtet Suchtext und Schalter „Kundenart fehlt“). */
export function stageCounts(customers, search = '', { nurKundenartFehlt = false } = {}) {
const counts = {}
for (const tab of CUSTOMER_TABS) counts[tab.id] = filterCustomers(customers, { stage: tab.id, search }).length
for (const tab of CUSTOMER_TABS) counts[tab.id] = filterCustomers(customers, { stage: tab.id, search, nurKundenartFehlt }).length
return counts
}
+4
View File
@@ -65,6 +65,10 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args)
if (info.invoiceNumber) err.invoiceNumber = info.invoiceNumber
// Team-Einstellungen der Oeffnungszeiten (PATCH): code (409 ZEITEN_VERALTET) und fehler je Feld (400)
if (typeof info.code === 'string') err.code = info.code
// Funktion einschalten ohne passendes Abo (409 abo_fehlt): der Grund (kein_abo, falsches_abo, gekuendigt, nicht_aktiv …) bestimmt den Text
if (typeof info.grund === 'string') err.grund = info.grund
// Bei grund beginnt_spaeter nennt das Portal den Beginn des Abos (ab, ISO-Zeitpunkt); featureFehlerText macht daraus das Datum
if (typeof info.ab === 'string') err.ab = info.ab
if (info.fehler && typeof info.fehler === 'object') err.fehler = info.fehler
throw err
}
+100 -10
View File
@@ -12,6 +12,7 @@ export const FUNKTIONEN = [
bestaetigen: {
activate: 'News ohne Zahlung aktivieren?',
disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
{
@@ -19,7 +20,7 @@ export const FUNKTIONEN = [
label: 'Terminplaner',
bestaetigen: {
activate: 'Terminplaner ohne Zahlung aktivieren?',
disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Bestellung (149 €) möglich.',
disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Einrichtung möglich.',
abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
@@ -112,8 +113,11 @@ export const AKTION_LABEL = {
* Abschalten, auch wenn das Ersatz-Ticket beim Abschalten fehlschlug (das Portal merkt sich das nicht; die Antwort nennt es nur einmal).
* hindernis (Portal: feature.hindernis): beim Terminplaner bleibt die Abnahme sichtbar und das Portal sperrt sie mit Begruendung
* (hindernisText); bei Oeffnungszeiten live entfaellt sie, solange der Kunde noch keine Zeiten eingetragen hat ('zeiten_fehlen').
* aboModell (aboModellFunktion(feature)): News, die das Abo-Modell eingeschaltet hat und die in "setup" stehen, bieten kein "Aktivieren
* (ohne Zahlung)": das Portal antwortet darauf mit 409 bereits_eingeschaltet. Es bleibt Abschalten, bei fehlendem Ticket davor "Ticket nachlegen"
* (die Route heilt auch ein fehlendes Ticket; ticketFreischaltung gilt im Portal auch fuer News). Alle anderen Faelle bleiben unveraendert.
*/
export function aktionen(state, key, { ticketMissing = false, hindernis = null } = {}) {
export function aktionen(state, key, { ticketMissing = false, hindernis = null, aboModell = false } = {}) {
if (key === 'termine' || key === 'shop' || key === 'zeiten' || key === 'unterkunft') {
if (state === 'ordered' || state === 'active') return ['disable']
if (state === 'setup') {
@@ -122,12 +126,20 @@ export function aktionen(state, key, { ticketMissing = false, hindernis = null }
}
return ['activate']
}
if (state === 'setup' && aboModell === true) return [...(ticketMissing ? ['ticket'] : []), 'disable']
const list = []
if (state !== 'active') list.push('activate')
if (['ordered', 'setup', 'active'].includes(state)) list.push('disable')
return list
}
/**
* true, wenn das Abo-Modell diese Funktion eingeschaltet hat: Portal feature.unlockedBy ist "abo" oder "projekt" (Marker von einschalten,
* Kauf-Bestand traegt "kauf", das Team "team", eine ausgeschaltete Funktion null). Das Portal nennt den Kunden nicht als Ganzen "im
* Abo-Modell"; das Dokument der Funktion ist die Stelle, an der es entscheidet. Fehlt das Feld (aelteres Portal), ist es false.
*/
export const aboModellFunktion = (feature) => feature?.unlockedBy === 'abo' || feature?.unlockedBy === 'projekt'
/** Warum der Terminplaner in "setup" noch nicht freigeschaltet werden kann (Portal: feature.hindernis) -> Hinweistext; sonst ''. */
const HINDERNIS_TEXT = {
kein_abo: 'Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.',
@@ -291,13 +303,83 @@ export function textformBereit({ confirmed, date, channel, today }, feld) {
return canSubmitTextform({ confirmed, date, channel, today, minDate, channels: FUNKTION_CHANNELS })
}
/** Rumpf fuer POST /funktionen/:customerId/:key; Datum und Weg der Textform nur beim Aktivieren und nur, wenn beide da sind. */
export function featureBody({ projectId, action, note, textformDate, textformChannel }) {
/** Wahl beim Aktivieren: wohin die Einrichtungszeit geht (Portal: POST /funktionen/:customerId/:key, Feld abrechnung). */
export const ABRECHNUNG_OPTIONEN = [
{ value: 'abo', label: 'Aus dem Abo' },
{ value: 'projekt', label: 'Im Projekt enthalten' },
]
/** Erklaerung unter der Wahl (ohne Preis). */
export const ABRECHNUNG_HINWEIS =
'Aus dem Abo: Die Einrichtungszeit geht aufs Stundenkonto des Kunden. Im Projekt enthalten: Sie zählt nicht aufs Stundenkonto und läuft über die Projektkosten.'
/**
* true, wenn diese Aktion die Wahl der Abrechnung braucht: "Aktivieren" einer Funktion aus FUNKTIONEN, ausser nach einer Team-Sperre
* (state "disabled" mit disabledReason "team"): dort wird nur wieder eingeschaltet, die Abrechnung steht schon.
*/
export const fragtAbrechnung = (key, action, feature) =>
action === 'activate' && FUNKTIONEN.some((f) => f.key === key) && !(feature?.state === 'disabled' && feature?.disabledReason === 'team')
/** Ohne Frage immer bereit, mit Frage nur mit "abo" oder "projekt". */
export const abrechnungBereit = ({ key, action, feature, abrechnung }) =>
!fragtAbrechnung(key, action, feature) || ABRECHNUNG_OPTIONEN.some((o) => o.value === abrechnung)
/**
* Text zu 409 abo_fehlt (Portal: grund = kein_abo | falsches_abo | gekuendigt | nicht_aktiv | beginnt_spaeter); unbekannter oder fehlender Grund
* -> allgemeiner Satz. ab (ISO-Zeitpunkt, nur bei beginnt_spaeter) nennt das Datum als Berliner Kalendertag; ohne brauchbares Datum bleibt es
* beim allgemeinen Satz.
*/
export function aboFehltText(grund, key, ab) {
const name = FUNKTIONEN.find((f) => f.key === key)?.label || key
switch (grund) {
case 'kein_abo':
return `${name}: Der Kunde hat kein laufendes Abo. Die Funktion läuft nur mit passendem Abo.`
case 'falsches_abo':
return `${name}: Das Hosting-Abo des Kunden enthält diese Funktion nicht. Sie ist im Wartungs-Abo enthalten.`
case 'gekuendigt':
return `${name}: Das Abo des Kunden ist gekündigt. Eingeschaltet wird nicht, solange die Kündigung vorgemerkt ist.`
case 'nicht_aktiv':
return `${name}: Das Abo des Kunden läuft nicht (Status nicht aktiv). Bitte zuerst das Abo klären.`
case 'beginnt_spaeter':
if (typeof ab === 'string' && ab !== '' && !Number.isNaN(Date.parse(ab))) {
return `${name}: Das Abo beginnt am ${fmtBerlin(ab)}. Ab dann kann die Funktion eingeschaltet werden.`
}
return `${name}: Das passende Abo fehlt.`
default:
return `${name}: Das passende Abo fehlt.`
}
}
/**
* Fehlertext zu einer Aktion. 409 abo_fehlt: der Text zum Grund (bei beginnt_spaeter mit Datum). 409 abgeschaltet und bereits_eingeschaltet
* (Team-Aktivierung im Abo-Modell): eigene Texte fuers Team, nie der Kundentext des Portals. Sonst die Meldung des Fehlers, ohne beides
* "Speichern fehlgeschlagen.".
*/
export function featureFehlerText(err, key) {
const name = FUNKTIONEN.find((f) => f.key === key)?.label || key
switch (err?.code) {
case 'abo_fehlt':
return aboFehltText(err.grund, key, err.ab)
case 'abgeschaltet':
return `${name}: Die Funktion wurde für dieses Projekt abgeschaltet (der Grund steht in der Zeile). Sie lässt sich hier nicht wieder einschalten.`
case 'bereits_eingeschaltet':
return `${name}: Die Funktion ist für dieses Projekt schon eingeschaltet. Bitte die Seite neu laden.`
default:
return err?.message || 'Speichern fehlgeschlagen.'
}
}
/**
* Rumpf fuer POST /funktionen/:customerId/:key; Datum und Weg der Textform nur beim Aktivieren und nur, wenn beide da sind,
* abrechnung nur beim Aktivieren und nur mit einem Wert aus ABRECHNUNG_OPTIONEN.
*/
export function featureBody({ projectId, action, note, textformDate, textformChannel, abrechnung }) {
const body = { projectId, action, note }
if (action === 'activate' && textformDate && textformChannel) {
body.textformDate = textformDate
body.textformChannel = textformChannel
}
if (action === 'activate' && ABRECHNUNG_OPTIONEN.some((o) => o.value === abrechnung)) body.abrechnung = abrechnung
return body
}
@@ -311,13 +393,21 @@ export const K16_WARNUNG_OHNE_UNTERLAGEN = 'Eingeschaltet, aber die E-Mail „be
const K16_OHNE_UNTERLAGEN = ['zeiten']
/**
* Statuszeile nach dem Speichern einer Aktion: Warnung statt "Gespeichert.", wenn die Antwort warnung: true traegt. Der Wortlaut der
* Warnung folgt der Funktion (key): Terminplaner und Webshop nennen Leistungsbeschreibung und AVV, Oeffnungszeiten live keinen AVV.
* Aufbauphase (Abo-Modell): das Team hat "Im Projekt enthalten" gewaehlt, obwohl der Kunde noch nie ein Abo hatte. Das Portal schaltet ein und
* antwortet mit 200 und warnung: 'abo_fehlt' (dazu grund: 'kein_abo'). Nicht zu verwechseln mit warnung: true (K16 ging nicht raus).
*/
export const speicherStatus = (antwort, key) =>
antwort?.warnung === true
? { ok: false, text: K16_OHNE_UNTERLAGEN.includes(key) ? K16_WARNUNG_OHNE_UNTERLAGEN : K16_WARNUNG }
: { ok: true, text: 'Gespeichert.' }
export const ABO_FEHLT_WARNUNG = 'Eingeschaltet. Achtung: Diese Funktion läuft nur mit Wartungs-Abo. Bitte das Abo vor dem Livegang vereinbaren.'
/**
* Statuszeile nach dem Speichern einer Aktion: Warnung statt "Gespeichert.", wenn die Antwort warnung: true (K16 nicht gesendet) oder
* warnung: 'abo_fehlt' (Aufbauphase ohne Abo) traegt. Der Wortlaut der K16-Warnung folgt der Funktion (key): Terminplaner und Webshop nennen
* Leistungsbeschreibung und AVV, Oeffnungszeiten live keinen AVV; die Abo-Warnung gilt fuer alle Funktionen gleich.
*/
export function speicherStatus(antwort, key) {
if (antwort?.warnung === true) return { ok: false, text: K16_OHNE_UNTERLAGEN.includes(key) ? K16_WARNUNG_OHNE_UNTERLAGEN : K16_WARNUNG }
if (antwort?.warnung === 'abo_fehlt') return { ok: false, text: ABO_FEHLT_WARNUNG }
return { ok: true, text: 'Gespeichert.' }
}
// --- Webshop: Anfrage des Kunden und Stand des Shops (Portal: GET /shop/:customerId) ---
+21
View File
@@ -12,6 +12,27 @@ export const KUNDENART_OPTIONEN = [
export const KUNDENART_HINWEIS = 'Für Auftragsbestätigungen: Privatpersonen bekommen die Widerrufsbelehrung.'
/** Auswahl für neue Eingaben (V9): ohne den Leer-Eintrag, es gibt nie eine Vorauswahl. */
export const KUNDENART_AUSWAHL = KUNDENART_OPTIONEN.filter((o) => o.value !== '')
export const KUNDENART_FEHLT_FEHLER = 'Bitte die Kundenart wählen.'
export const KUNDENART_AUSWAHL_HINWEIS =
'Unternehmen bestellt als Betrieb, Privatperson als Verbraucher mit Widerrufsrecht. Das Portal fragt den Kunden nicht danach.'
export const KUNDENART_HOCHSTUFEN_HINWEIS =
'Bitte zuerst die Kundenart festlegen (unter „Rechnungsdaten“), dann „Zum festen Kunden machen“.'
/** Gesetzt heisst genau 'unternehmen' oder 'privat'; Gross-/Kleinschreibung, Leerzeichen, null und Zahlen gelten nicht. */
export function kundenartGesetzt(value) {
return typeof value === 'string' && KUNDENARTEN.includes(value)
}
/** Pflichtpruefung fuer neue Eingaben: null = in Ordnung, sonst der Fehlertext. */
export function kundenartPruefen(value) {
return kundenartGesetzt(value) ? null : KUNDENART_FEHLT_FEHLER
}
const text = (v) => (typeof v === 'string' ? v : '')
/** Beschriftung der Kundenart; alles Unbekannte heisst „Nicht festgelegt“. */
+6 -2
View File
@@ -1,5 +1,6 @@
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from './appwrite'
import { rejectionLine, notesWithRejection } from './leadDuplicates'
import { kundenartPruefen } from './kundenFelder'
/** Pflichtfelder eines festen Kunden: Name, E-Mail und vollständige Anschrift (Voraussetzung für „Zum festen Kunden machen“). */
export function isInvoiceReady(c) {
@@ -55,13 +56,16 @@ export async function createAcquisitionTicket(customer, user) {
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ID.unique(), data)
}
/** Legt einen Lead an (Unternehmen, E-Mail, Telefon) und erstellt das Akquise-Ticket. */
export async function createLead({ company, email, phone }, user) {
/** Legt einen Lead an (Unternehmen, E-Mail, Telefon, Kundenart) und erstellt das Akquise-Ticket. Ohne gültige Kundenart wird nichts geschrieben. */
export async function createLead({ company, email, phone, kundenart }, user) {
const fehler = kundenartPruefen(kundenart)
if (fehler) throw new Error(fehler)
const customer = await databases.createDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ID.unique(), {
name: company,
companyName: company,
email: email || '',
phone: phone || '',
kundenart,
customerStatus: 'lead',
updatedAt: new Date().toISOString(),
})
+32
View File
@@ -0,0 +1,32 @@
// Abo-Vorgabe für Arbeitsblätter an Einrichtungs-Tickets (V9): Hängt das Ticket an einer Funktion, die aus dem Abo
// eingerichtet wird (einrichtungAbrechnung 'abo'), steht "Vom Abo-Stundenkonto abziehen" von Anfang an auf an.
// 'projekt' (im Projekt enthalten) und alle anderen Tickets bekommen keine Vorgabe. Nur Vergleiche, keine Zeitlogik.
import { isGitWorksheet, needsTimeEntry } from './ticketForm'
export const EINRICHTUNG_ABRECHNUNGEN = ['abo', 'projekt']
// 'abo' | 'projekt' | '' – nur genau diese Werte, alles andere (Großschreibung, Zahl, fehlendes Ticket) gilt als nicht gesetzt
export function einrichtungAbrechnung(ticket) {
const wert = ticket?.einrichtungAbrechnung
return EINRICHTUNG_ABRECHNUNGEN.includes(wert) ? wert : ''
}
// Vorgabe für countsToPlan: nur bei Abo-Einrichtung und wenn das Wartungs-Abo Stunden erlaubt
export function countsToPlanVorgabe(ticket, aboHoursAllowed) {
return einrichtungAbrechnung(ticket) === 'abo' && aboHoursAllowed === true
}
// Wert des Hakens im Dialog. gewaehlt: null = unberührt (dann gilt die Vorgabe, auch wenn der Abo-Status erst nach dem
// Öffnen ankommt), true/false = von Hand gesetzt. Ohne erlaubte Abo-Stunden und bei Kommentar ist der Wert immer false.
export function countsToPlanWert({ gewaehlt, ticket, aboHoursAllowed, isComment }) {
if (isComment || aboHoursAllowed !== true) return false
if (gewaehlt === null || gewaehlt === undefined) return countsToPlanVorgabe(ticket, aboHoursAllowed)
return Boolean(gewaehlt)
}
// Felder, die beim Nachtragen der Zeit eines Git-Blatts mitgeschrieben werden: nur beim ersten Eintragen der Zeit
// (das Blatt hat noch keine) und nur, wenn die Vorgabe greift. Eine bestehende Entscheidung bleibt unberührt.
export function nachtragPlanFelder({ worksheet, ticket, aboHoursAllowed }) {
if (!isGitWorksheet(worksheet) || !needsTimeEntry(worksheet) || worksheet.countsToPlan) return {}
return countsToPlanVorgabe(ticket, aboHoursAllowed) ? { countsToPlan: true } : {}
}
+56
View File
@@ -0,0 +1,56 @@
// Anzeige des Stundenkontos eines Kunden (V8) und der Abrechnung einer Einrichtung (V9). Nur Aufbereitung von Text,
// keine Abrufe: die Antwort von GET /api/team/customers/:id/stundenkonto kommt von außen. Alles Unbrauchbare wird still ausgelassen.
import { stepLabel } from './timeSteps'
import { fmtDate } from './format'
import { einrichtungAbrechnung } from './planVorgabe'
const istObjekt = (x) => x !== null && typeof x === 'object' && !Array.isArray(x)
// Ganze Minuten, nie negativ: Text, NaN, Unendlich und fehlende Werte gelten als 0
const minuten = (x) => {
const n = Math.round(Number(x))
return Number.isFinite(n) && n > 0 ? n : 0
}
const lesbar = (at) => typeof at === 'string' && at !== '' && !Number.isNaN(new Date(at).getTime())
const HINWEIS_HOSTING = 'Hosting-Abo: kein Stundenkonto.'
const HINWEIS_KEINS = 'Kein Abo: kein Stundenkonto.'
// Vorschuss (Einrichtung ohne Deckung): mit laufendem Abo geht er in die nächsten Gutschriften, nach dem Ende des Abos verfällt er (E17: WEBklar trägt den Rest)
const SUB_VORSCHUSS = 'wird mit den nächsten Gutschriften verrechnet'
const SUB_VORSCHUSS_ENDE = 'verfällt, wird nicht berechnet'
// null (keine brauchbare Antwort) | { art: 'konto'|'hosting'|'keins', hinweis, eintraege: [{ key, label, wert, sub, ton }] }
// Reihenfolge: Guthaben, Vorschuss (nur > 0), Mehraufwand (nur > 0), Verfall (nur mit Minuten und lesbarem Datum).
// antwort.active (Portal, boolean, Abo läuft): nur ein ausdrückliches false ändert den Satz zum Vorschuss; fehlt das Feld (altes Portal) oder ist es
// etwas anderes, gilt wie bisher "wird mit den nächsten Gutschriften verrechnet".
export function stundenkontoAnsicht(antwort) {
if (!istObjekt(antwort)) return null
const ledger = antwort.ledger
if (!istObjekt(ledger)) {
if (antwort.produkt === 'hosting') return { art: 'hosting', hinweis: HINWEIS_HOSTING, eintraege: [] }
// Ein Wartungs-Abo ohne Konto widerspricht dem Vertrag: lieber nichts zeigen als "Kein Abo" zu behaupten
if (antwort.produkt === 'wartung') return null
return { art: 'keins', hinweis: HINWEIS_KEINS, eintraege: [] }
}
const eintraege = [{ key: 'guthaben', label: 'Guthaben', wert: stepLabel(minuten(ledger.balanceMinutes)), sub: '', ton: '' }]
const vorschuss = minuten(ledger.vorschussMinutes)
if (vorschuss > 0) {
eintraege.push({ key: 'vorschuss', label: 'Vorschuss (Einrichtung)', wert: stepLabel(vorschuss), sub: antwort.active === false ? SUB_VORSCHUSS_ENDE : SUB_VORSCHUSS, ton: 'warn' })
}
const mehraufwand = minuten(ledger.overageMinutes)
if (mehraufwand > 0) {
eintraege.push({ key: 'mehraufwand', label: 'Mehraufwand', wert: stepLabel(mehraufwand), sub: '', ton: 'warn' })
}
const verfall = istObjekt(ledger.nextExpiry) ? ledger.nextExpiry : null
if (verfall && minuten(verfall.minutes) > 0 && lesbar(verfall.at)) {
eintraege.push({ key: 'verfall', label: 'Nächster Verfall', wert: stepLabel(minuten(verfall.minutes)), sub: `am ${fmtDate(verfall.at)}`, ton: '' })
}
return { art: 'konto', hinweis: '', eintraege }
}
// Satz zur Abrechnung der Einrichtung an einem Einrichtungs-Ticket; '' ohne Marker am Ticket
export function einrichtungText(ticket) {
const art = einrichtungAbrechnung(ticket)
if (art === 'abo') return 'Einrichtung aus dem Abo: Die Arbeitszeit geht aufs Stundenkonto des Kunden, auch wenn das Guthaben nicht reicht.'
if (art === 'projekt') return 'Einrichtung im Projekt enthalten: Die Arbeitszeit zählt nicht aufs Abo-Stundenkonto.'
return ''
}
+8 -2
View File
@@ -98,9 +98,10 @@ export const getFunktionen = (customerId) => request(`/funktionen/${enc(customer
/**
* Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine | shop | zeiten | unterkunft); Antwort { projects: [das eine Projekt] }.
* Terminplaner und Webshop im Modus on: beim Aktivieren zusaetzlich textformDate (JJJJ-MM-TT) und textformChannel (chat | email).
* abrechnung ('abo' | 'projekt', nur beim Aktivieren): wohin die Einrichtungszeit geht; 409 abo_fehlt bringt err.code und err.grund mit (featureFehlerText).
*/
export const setFeature = (customerId, key, { projectId, action, note, textformDate, textformChannel }) =>
request(`/funktionen/${enc(customerId)}/${enc(key)}`, { method: 'POST', body: featureBody({ projectId, action, note, textformDate, textformChannel }) })
export const setFeature = (customerId, key, { projectId, action, note, textformDate, textformChannel, abrechnung }) =>
request(`/funktionen/${enc(customerId)}/${enc(key)}`, { method: 'POST', body: featureBody({ projectId, action, note, textformDate, textformChannel, abrechnung }) })
export const setNewsFeature = (customerId, body) => setFeature(customerId, 'news', body)
/** Einbauanleitung des Terminplaners: { stand, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis } (Aufbereitung: panelModell). */
export const getTermineAnleitung = (customerId, projectId) => request(anleitungPfad(customerId, projectId))
@@ -136,3 +137,8 @@ export const getAbgerechnet = (ticketId) => request(abgerechnetPfad(ticketId))
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
export const getCustomerAboStatus = (customerId) => request(`/customers/${enc(customerId)}/abo`)
// --- Stundenkonto (alle Mitarbeiter) ---
/** { produkt: 'hosting' | 'wartung' | null, active?: boolean (Abo läuft; fehlt bei einem älteren Portal), ledger: { balanceMinutes, vorschussMinutes, overageMinutes, nextExpiry: { minutes, at } | null } | null } (Aufbereitung: stundenkontoView) */
export const getCustomerStundenkonto = (customerId) => request(`/customers/${enc(customerId)}/stundenkonto`)
+22 -3
View File
@@ -1,6 +1,11 @@
/* Folgen eines neuen Arbeitsblatts.
Kennt kein Appwrite: die Seite reicht die Schreibzugriffe herein. */
import { kundenartGesetzt } from './kundenFelder'
export const KUNDENART_ZUGESAGT_HINWEIS =
'Das Arbeitsblatt ist gespeichert, der Lead wurde aber noch nicht zum festen Kunden gemacht: Die Kundenart fehlt. Lege sie beim Kunden unter „Rechnungsdaten“ fest und wähle dann „Zum festen Kunden machen“.'
// Akquise-Automatik: das Ergebnis im Arbeitsblatt steuert den Kundenstatus.
// "customer" = Zugesagt, "lost" = Abgesagt, sonst keine Aenderung.
export function acquisitionOutcome(ticket, newStatus) {
@@ -10,10 +15,21 @@ export function acquisitionOutcome(ticket, newStatus) {
return null
}
// Ist die Kundenart des Kunden gesetzt? Fail-closed: nicht ladbar, null oder ohne getCustomer heisst "nicht gesetzt".
async function kundenartDesKunden(getCustomer, customerId) {
try {
const doc = await getCustomer?.(customerId)
return kundenartGesetzt(doc?.kundenart)
} catch {
return false
}
}
/* 1. Arbeitsblatt anlegen. 2. Bei Erfolg den Ticket-Status nachziehen, wenn er abweicht.
3. Bei Akquise-Tickets den Kundenstatus setzen (Fehler werden verschluckt).
Rueckgabe ist das Ergebnis von createWorksheet. */
export async function createWorksheetFlow({ ticket, worksheetData, user, createWorksheet, updateTicket, updateCustomer, now = () => new Date() }) {
3. Bei Akquise-Tickets den Kundenstatus setzen (Fehler werden verschluckt). "Zugesagt" stuft den Lead nur hoch,
wenn seine Kundenart gesetzt ist; sonst bleiben Blatt und Ticketstatus, der Lead bleibt Lead und das Ergebnis traegt
einen Hinweis. Rueckgabe ist das Ergebnis von createWorksheet (ggf. mit hinweis). */
export async function createWorksheetFlow({ ticket, worksheetData, user, createWorksheet, updateTicket, updateCustomer, getCustomer, now = () => new Date() }) {
const result = await createWorksheet(worksheetData, user)
if (!result?.success) return result
@@ -23,6 +39,9 @@ export async function createWorksheetFlow({ ticket, worksheetData, user, createW
}
const customerStatus = acquisitionOutcome(ticket, newStatus)
if (customerStatus === 'customer' && !(await kundenartDesKunden(getCustomer, ticket.customerId))) {
return { ...result, hinweis: KUNDENART_ZUGESAGT_HINWEIS }
}
if (customerStatus) {
try {
await updateCustomer(ticket.customerId, { customerStatus, updatedAt: now().toISOString() })
+2 -1
View File
@@ -12,7 +12,7 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer, markLost, reopenAsLead } from '../lib/leads'
import { lastRejection } from '../lib/leadDuplicates'
import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartAenderung } from '../lib/kundenFelder'
import { KUNDENART_OPTIONEN, KUNDENART_HINWEIS, KUNDENART_HOCHSTUFEN_HINWEIS, kundenartAenderung, kundenartPruefen } from '../lib/kundenFelder'
import { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList'
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
import { ticketNumber } from '../lib/ticketView'
@@ -196,6 +196,7 @@ function OverviewTab({ customer, stage, onSaved }) {
}
const upgrade = async () => {
if (kundenartPruefen(kundenart)) { setMsg({ tone: 'warn', text: KUNDENART_HOCHSTUFEN_HINWEIS }); return }
if (!ready) { setMsg({ tone: 'warn', text: 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.' }); return }
setBusy(true); setMsg(null)
try {
+24 -6
View File
@@ -3,10 +3,12 @@ import { Link, useNavigate } from 'react-router-dom'
import { useCustomers } from '../hooks/useCustomers'
import { useAuth } from '../context/AuthContext'
import { createLead } from '../lib/leads'
import { CUSTOMER_TABS, customerBadges, customerTitle, filterCustomers, stageCounts } from '../lib/customerList'
import { CUSTOMER_TABS, customerBadges, customerTitle, filterCustomers, kundenartFehltAnzahl, stageCounts } from '../lib/customerList'
import { isRowActivation } from '../lib/rowClick'
import { findDuplicates } from '../lib/leadDuplicates'
import DuplicateWarning from '../components/customer/DuplicateWarning'
import KundenartAuswahl from '../components/KundenartAuswahl'
import { kundenartPruefen, KUNDENART_AUSWAHL_HINWEIS } from '../lib/kundenFelder'
import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui'
export default function CustomersPage() {
@@ -17,10 +19,11 @@ export default function CustomersPage() {
// 105 von 110 Eintraegen sind Leads der Lead-Automatik, deshalb startet die Liste bei den festen Kunden
const [stage, setStage] = useState('customer') // customer | lead | lost | archived | all
const [showCreate, setShowCreate] = useState(null) // null | 'lead' | 'customer'
const [nurFehlt, setNurFehlt] = useState(false) // Schalter „Kundenart fehlt“
const filtered = useMemo(() => filterCustomers(customers, { stage, search }), [customers, stage, search])
// Zähler mit derselben Funktion wie die Liste (beachten den Suchtext); bis zum Laden ohne Zahl
const counts = useMemo(() => stageCounts(customers, search), [customers, search])
const filtered = useMemo(() => filterCustomers(customers, { stage, search, nurKundenartFehlt: nurFehlt }), [customers, stage, search, nurFehlt])
// Zähler mit derselben Funktion wie die Liste (beachten Suchtext und Schalter); bis zum Laden ohne Zahl
const counts = useMemo(() => stageCounts(customers, search, { nurKundenartFehlt: nurFehlt }), [customers, search, nurFehlt])
const tabs = CUSTOMER_TABS.map((tab) => ({ id: tab.id, label: tab.label, count: loading ? undefined : counts[tab.id] }))
let content
@@ -98,6 +101,10 @@ export default function CustomersPage() {
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<label className="check">
<input type="checkbox" checked={nurFehlt} onChange={(e) => setNurFehlt(e.target.checked)} />
Kundenart fehlt{loading ? '' : ` (${kundenartFehltAnzahl(customers)})`}
</label>
</div>
{content}
@@ -138,6 +145,7 @@ function CreateLeadForm({ customers, onCancel, onDone }) {
const [company, setCompany] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [kundenart, setKundenart] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
// Abgleich mit allen Kunden (auch archivierten und abgesagten), damit niemand doppelt angerufen wird
@@ -146,9 +154,11 @@ function CreateLeadForm({ customers, onCancel, onDone }) {
const submit = async (e) => {
e.preventDefault()
if (!company.trim()) { setError('Unternehmen erforderlich'); return }
const f = kundenartPruefen(kundenart)
if (f) { setError(f); return }
setBusy(true); setError(null)
try {
await onDone({ company: company.trim(), email: email.trim(), phone: phone.trim() })
await onDone({ company: company.trim(), email: email.trim(), phone: phone.trim(), kundenart })
} catch (err) {
setError(err.message || 'Fehler')
setBusy(false)
@@ -189,13 +199,16 @@ function CreateLeadForm({ customers, onCancel, onDone }) {
<input id="lead-phone" className="input" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
</Field>
</div>
<Field label="Kundenart" hint={KUNDENART_AUSWAHL_HINWEIS}>
<KundenartAuswahl value={kundenart} onChange={setKundenart} disabled={busy} />
</Field>
</Dialog>
)
}
// Fester Kunde mit Zugang zum Kundenportal (nur Admins; legt den Login im Portal an)
export function CreateCustomerForm({ customers, onCancel, onCreate }) {
const [form, setForm] = useState({ name: '', email: '', code: '', phone: '', location: '', password: '' })
const [form, setForm] = useState({ name: '', email: '', code: '', phone: '', location: '', password: '', kundenart: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const matches = useMemo(
@@ -207,6 +220,8 @@ export function CreateCustomerForm({ customers, onCancel, onCreate }) {
const submit = async (e) => {
e.preventDefault()
if (form.password.length < 8) { setError('Das Portal-Passwort muss mindestens 8 Zeichen haben.'); return }
const f = kundenartPruefen(form.kundenart)
if (f) { setError(f); return }
setBusy(true); setError(null)
const r = await onCreate({ ...form, name: form.name.trim(), email: form.email.trim() })
if (!r.success) {
@@ -255,6 +270,9 @@ export function CreateCustomerForm({ customers, onCancel, onCreate }) {
<input id="customer-password" className="input" type="password" autoComplete="new-password" name="password" value={form.password} onChange={set('password')} required />
</Field>
</div>
<Field label="Kundenart" hint={KUNDENART_AUSWAHL_HINWEIS}>
<KundenartAuswahl value={form.kundenart} onChange={(v) => setForm((f) => ({ ...f, kundenart: v }))} disabled={busy} />
</Field>
</Dialog>
)
}
+23 -1
View File
@@ -4,6 +4,7 @@ import { PageHeader, Tabs, Card, EmptyState, Button } from '../components/ui'
import UrgentToggle from '../components/UrgentToggle'
import WorksheetSummary from '../components/WorksheetSummary'
import WorksheetList from '../components/WorksheetList'
import StundenkontoLeiste from '../components/customer/StundenkontoLeiste'
import ProjectContextPanel from '../components/ProjectContextPanel'
import TicketProjects from '../components/TicketProjects'
import CreateWorksheetModal from '../components/CreateWorksheetModal'
@@ -18,6 +19,9 @@ import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
import { elapsedLabel } from '../lib/elapsed'
import { canAddOwnSupport } from '../lib/support'
import { canEditWorksheetTimes, serviceTypeLabel } from '../lib/ticketForm'
import { einrichtungAbrechnung, nachtragPlanFelder } from '../lib/planVorgabe'
import { einrichtungText } from '../lib/stundenkontoView'
import { worksheetSums } from '../lib/worksheetView'
import { listBackPath, resolveTicketTab, ticketPath, ticketRef } from '../lib/ticketRoutes'
import { statusSelectOptions, ticketTypeLabel } from '../lib/ticketStatus'
import { appointmentText, assigneeOptions, dueText, ticketNumber } from '../lib/ticketView'
@@ -90,6 +94,8 @@ function TicketView({ ticket, updateWorkorder, tab }) {
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [editTimesWorksheet, setEditTimesWorksheet] = useState(null)
const [ownSupportWorksheet, setOwnSupportWorksheet] = useState(null)
// Hinweis des Arbeitsblatt-Flusses (z. B. "Zugesagt" ohne Kundenart); jedes neue Blatt setzt ihn neu
const [hinweis, setHinweis] = useState('')
// Werte, die gerade gespeichert werden: das Feld zeigt den neuen Wert und ist gesperrt, bei einem Fehler springt es zurück
const [pending, setPending] = useState({})
@@ -109,6 +115,9 @@ function TicketView({ ticket, updateWorkorder, tab }) {
tab === 'arbeitsblaetter' || showCreateWorksheet
)
// Einrichtungs-Ticket: 'abo' | 'projekt' | '' (V9); bei 'abo' zeigt die Seite das Stundenkonto des Kunden
const einrichtung = einrichtungAbrechnung(ticket)
const isPending = (field) => pending[field] !== undefined
const shown = (field, current) => (isPending(field) ? pending[field] : current)
@@ -140,7 +149,9 @@ function TicketView({ ticket, updateWorkorder, tab }) {
createWorksheet,
updateTicket: updateWorkorder,
updateCustomer: (id, data) => databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id, data),
getCustomer: (id) => databases.getDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id),
})
setHinweis(result?.hinweis || '')
// Das neue Blatt soll sichtbar sein
if (result?.success && tab !== 'arbeitsblaetter') goToTab('arbeitsblaetter')
return result
@@ -238,6 +249,8 @@ function TicketView({ ticket, updateWorkorder, tab }) {
</div>
</div>
{hinweis && <p className="msg warn mb-2" role="alert">{hinweis}</p>}
<Tabs
tabs={[
{ id: 'details', label: 'Details' },
@@ -247,6 +260,15 @@ function TicketView({ ticket, updateWorkorder, tab }) {
onChange={(id) => { if (id !== tab) goToTab(id) }}
/>
{einrichtung && (
<>
<p className="muted mb-2">{einrichtungText(ticket)}</p>
{einrichtung === 'abo' && (
<StundenkontoLeiste customerId={ticket.customerId} reloadKey={`${worksheets.length}:${worksheetSums(worksheets).aboMinutes}`} />
)}
</>
)}
{tab === 'details' && (
<>
<Card title="Ticket-Beschreibung">
@@ -301,7 +323,7 @@ function TicketView({ ticket, updateWorkorder, tab }) {
worksheet={editTimesWorksheet}
currentUserId={user?.$id}
onClose={() => setEditTimesWorksheet(null)}
onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee)}
onSave={(ws, times) => updateWorksheetTimes(ws, times, user, employee, nachtragPlanFelder({ worksheet: ws, ticket, aboHoursAllowed }))}
employeesState={employeesState}
/>
)}
+1
View File
@@ -411,6 +411,7 @@ a.list-row:hover .cell-title { color: var(--accent-ink); }
.seg { display: inline-flex; padding: 2px; border-radius: var(--radius); background: var(--bg-hover); gap: 2px; }
.seg button { height: 28px; padding: 0 12px; border-radius: 5px; color: var(--text-2); font-weight: 500; }
.seg button.on, .seg button[aria-pressed="true"] { background: var(--bg); color: var(--text); box-shadow: 0 1px 2px rgba(26,31,54,.12), 0 0 0 1px rgba(26,31,54,.04); }
.field > .seg { align-self: flex-start; }
.toast {
position: fixed;
+30 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { CUSTOMER_TABS, customerStage, isArchived, filterCustomers, stageCounts, customerBadges, stageLabel, customerEyebrow, customerTitle } =
const { CUSTOMER_TABS, customerStage, isArchived, filterCustomers, stageCounts, customerBadges, stageLabel, customerEyebrow, customerTitle, kundenartFehlt, kundenartFehltAnzahl } =
await loadModule('src/lib/customerList.js')
const A = { $id: 'a', name: 'Alpha GmbH', code: 'K-1', location: 'Kaiserslautern', email: 'info@alpha.de', contactName: 'Anna Alt', customerStatus: 'customer' }
@@ -74,3 +74,32 @@ test('stageLabel, customerEyebrow, customerTitle', () => {
assert.equal(customerTitle({ email: 'x@y.de' }), 'x@y.de')
assert.equal(customerTitle({}), '–')
})
const KA = [
{ $id: 'k1', name: 'Eins', customerStatus: 'customer', kundenart: 'unternehmen' },
{ $id: 'k2', name: 'Zwei', customerStatus: 'customer' },
{ $id: 'k3', name: 'Drei', customerStatus: 'lead', kundenart: '' },
{ $id: 'k4', name: 'Vier', customerStatus: 'lead', kundenart: 'privat' },
{ $id: 'k5', name: 'Fünf', customerStatus: 'lost' },
{ $id: 'k6', name: 'Sechs', customerStatus: 'customer', archived: true },
{ $id: 'k7', name: 'Sieben', customerStatus: 'customer', kundenart: ' unternehmen' },
]
test('kundenartFehlt und kundenartFehltAnzahl: Lead und fester Kunde, ohne Archiv und Abgesagte', () => {
assert.equal(kundenartFehlt({}), true); assert.equal(kundenartFehlt({ kundenart: 'privat' }), false); assert.equal(kundenartFehlt(null), true)
assert.equal(kundenartFehltAnzahl(KA), 3) // k2, k3, k7
assert.equal(kundenartFehltAnzahl([]), 0)
})
test('filterCustomers mit nurKundenartFehlt: je Reiter, mit Suche; ohne Schalter unverändert', () => {
const f = (opts) => ids(filterCustomers(KA, opts))
assert.deepEqual(f({ stage: 'customer', nurKundenartFehlt: true }), ['k2', 'k7'])
assert.deepEqual(f({ stage: 'lead', nurKundenartFehlt: true }), ['k3'])
assert.deepEqual(f({ stage: 'lost', nurKundenartFehlt: true }), ['k5'])
assert.deepEqual(f({ stage: 'archived', nurKundenartFehlt: true }), ['k6'])
assert.deepEqual(f({ stage: 'all', nurKundenartFehlt: true }), ['k2', 'k3', 'k5', 'k7'])
assert.deepEqual(f({ stage: 'all', search: 'zwei', nurKundenartFehlt: true }), ['k2'])
assert.deepEqual(f({ stage: 'customer' }), ['k1', 'k2', 'k7'])
})
test('stageCounts mit nurKundenartFehlt: dieselbe Filterfunktion wie die Liste', () => {
assert.deepEqual(stageCounts(KA, '', { nurKundenartFehlt: true }), { customer: 2, lead: 1, lost: 1, archived: 1, all: 4 })
assert.deepEqual(stageCounts(KA), { customer: 3, lead: 2, lost: 1, archived: 1, all: 6 })
})
+21
View File
@@ -144,3 +144,24 @@ test('Fehlercode und Feldmeldungen des Portals bleiben am Fehler (409 ZEITEN_VER
assert.equal(ohne.code, undefined)
assert.equal(ohne.fehler, undefined)
})
test('Der Grund einer Abo-Sperre bleibt am Fehler (409 abo_fehlt mit grund)', async () => {
const { api } = client({ responses: [reply(409, { error: 'x', code: 'abo_fehlt', grund: 'kein_abo' }), reply(409, { error: 'y', code: 'abo_fehlt', grund: 5 })] })
const a = await fails(api('/funktionen/c1/news', { method: 'POST', body: {} }))
assert.deepEqual([a.status, a.code, a.grund], [409, 'abo_fehlt', 'kein_abo'])
assert.equal((await fails(api('/funktionen/c1/news', { method: 'POST', body: {} }))).grund, undefined)
})
test('Das Datum eines späteren Abo-Beginns bleibt am Fehler (409 abo_fehlt, beginnt_spaeter mit ab)', async () => {
const { api } = client({
responses: [
reply(409, { error: 'x', code: 'abo_fehlt', grund: 'beginnt_spaeter', ab: '2026-11-03T08:00:00.000Z' }),
reply(409, { error: 'y', code: 'abo_fehlt', grund: 'beginnt_spaeter', ab: 5 }),
reply(409, { error: 'z', code: 'abo_fehlt', grund: 'beginnt_spaeter' }),
],
})
const a = await fails(api('/funktionen/c6/news', { method: 'POST', body: {} }))
assert.deepEqual([a.status, a.code, a.grund, a.ab], [409, 'abo_fehlt', 'beginnt_spaeter', '2026-11-03T08:00:00.000Z'])
assert.equal((await fails(api('/funktionen/c6/news', { method: 'POST', body: {} }))).ab, undefined) // kein Text
assert.equal((await fails(api('/funktionen/c6/news', { method: 'POST', body: {} }))).ab, undefined) // fehlt
})
+184 -3
View File
@@ -9,6 +9,7 @@ const {
AKTION_LABEL,
funktionOf,
aktionen,
aboModellFunktion,
mergeFunktion,
siteKeyOf,
abgeschaltetText,
@@ -28,9 +29,16 @@ const {
textformAnsicht,
textformBereit,
featureBody,
ABRECHNUNG_OPTIONEN,
ABRECHNUNG_HINWEIS,
fragtAbrechnung,
abrechnungBereit,
aboFehltText,
featureFehlerText,
speicherStatus,
K16_WARNUNG,
K16_WARNUNG_OHNE_UNTERLAGEN,
ABO_FEHLT_WARNUNG,
warenartLabel,
artikelzahlLabel,
anfrageStatus,
@@ -52,6 +60,7 @@ test('FUNKTIONEN: News, Terminplaner, Webshop und Öffnungszeiten live (in diese
bestaetigen: {
activate: 'News ohne Zahlung aktivieren?',
disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
{
@@ -59,7 +68,7 @@ test('FUNKTIONEN: News, Terminplaner, Webshop und Öffnungszeiten live (in diese
label: 'Terminplaner',
bestaetigen: {
activate: 'Terminplaner ohne Zahlung aktivieren?',
disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Bestellung (149 €) möglich.',
disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Einrichtung möglich.',
abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
@@ -106,12 +115,13 @@ test('FUNKTIONEN: Schlüssel sind eindeutig und jede Funktion hat Aktivieren und
for (const text of Object.values(zeiten)) assert.doesNotMatch(text, /24 Uhr/, 'nie "24 Uhr"')
})
test('W1: der Abschalten-Dialog des Terminplaners nennt Buchungsstopp, Löschung nach 30 Tagen und die neue Bestellung (149 €)', () => {
test('W1: der Abschalten-Dialog des Terminplaners nennt Buchungsstopp, Löschung nach 30 Tagen und die neue Einrichtung, aber keinen Preis', () => {
const text = FUNKTIONEN.find((f) => f.key === 'termine').bestaetigen.disable
assert.match(text, /^Terminplaner abschalten\?/)
assert.match(text, /keine Buchungen mehr möglich/, 'Buchungen stehen')
assert.match(text, /Nach 30 Tagen werden alle Termine und Einstellungen gelöscht/, 'Löschung nach 30 Tagen')
assert.match(text, /nur mit einer neuen Bestellung \(149 €\)/, 'danach nur mit neuer Bestellung, mit Preis')
assert.match(text, /nur mit einer neuen Einrichtung möglich/, 'danach nur mit neuer Einrichtung')
assert.doesNotMatch(text, /€|Euro|Bestellung/, 'kein Preis')
// News bleibt unverändert: dort wird nichts gelöscht
assert.equal(FUNKTIONEN.find((f) => f.key === 'news').bestaetigen.disable, 'News abschalten? Die Website zeigt dann keine Beiträge mehr.')
})
@@ -160,6 +170,69 @@ test('aktionen: News ignoriert ticketMissing in jedem Zustand', () => {
}
})
test('aboModellFunktion: nur Funktionen, die das Abo-Modell eingeschaltet hat (unlockedBy "abo" oder "projekt")', () => {
assert.equal(aboModellFunktion({ state: 'setup', unlockedBy: 'abo' }), true)
assert.equal(aboModellFunktion({ state: 'setup', unlockedBy: 'projekt' }), true)
for (const unlockedBy of ['kauf', 'team', null, undefined, '', 'Abo', 'ABO', ' abo', 'projekt ', 5, true, ['abo']]) {
assert.equal(aboModellFunktion({ state: 'setup', unlockedBy }), false, String(unlockedBy))
}
for (const feature of [undefined, null, {}, 'abo', 5]) assert.equal(aboModellFunktion(feature), false, String(feature))
})
test('aktionen W2: News im Abo-Modell in "Einrichtung" bietet kein Aktivieren (der Server antwortet 409 bereits_eingeschaltet), nur Abschalten', () => {
assert.deepEqual(aktionen('setup', 'news', { aboModell: true }), ['disable'])
assert.deepEqual(aktionen('setup', undefined, { aboModell: true }), ['disable'], 'ohne Schlüssel wie News')
assert.deepEqual(aktionen('setup', 'news', { aboModell: true, hindernis: 'kein_abo' }), ['disable'])
})
test('aktionen W2: fehlt das Einrichtungs-Ticket, steht an der Stelle von Aktivieren "Ticket nachlegen" (dafür gibt es die Route, Aktivieren nicht)', () => {
assert.deepEqual(aktionen('setup', 'news', { aboModell: true, ticketMissing: true }), ['ticket', 'disable'])
assert.deepEqual(aktionen('setup', 'news', { aboModell: true, ticketMissing: false }), ['disable'])
for (const action of aktionen('setup', 'news', { aboModell: true, ticketMissing: true })) {
assert.ok(FUNKTIONEN.find((f) => f.key === 'news').bestaetigen[action], `Text für ${action}`)
}
assert.equal(FUNKTIONEN.find((f) => f.key === 'news').bestaetigen.ticket, 'Fehlendes Einrichtungs-Ticket neu anlegen?')
})
test('aktionen W2: außerhalb von "Einrichtung" und ohne Abo-Modell bleibt alles wie bisher', () => {
// andere Zustände: das Abo-Modell ändert nichts
for (const state of ['off', 'ordered', 'active', 'disabled', undefined]) {
for (const ticketMissing of [false, true]) {
assert.deepEqual(aktionen(state, 'news', { aboModell: true, ticketMissing }), aktionen(state, 'news'), String(state))
}
}
// ohne Abo-Modell (Kauf, Team, altes Portal ohne unlockedBy): Aktivieren in "Einrichtung" bleibt, ticketMissing wird ignoriert
assert.deepEqual(aktionen('setup', 'news', { aboModell: false }), ['activate', 'disable'])
assert.deepEqual(aktionen('setup', 'news', { aboModell: false, ticketMissing: true }), ['activate', 'disable'])
assert.deepEqual(aktionen('setup', 'news', {}), ['activate', 'disable'])
assert.deepEqual(aktionen('setup', 'news', { aboModell: 'abo' }), ['activate', 'disable'], 'nur ein echtes true zählt')
// ticketbasierte Funktionen sind vom Abo-Modell unberührt (dort gibt es in "Einrichtung" nie Aktivieren)
for (const key of ['termine', 'shop', 'zeiten', 'unterkunft']) {
for (const state of ['off', 'ordered', 'setup', 'active', 'disabled', undefined]) {
for (const ticketMissing of [false, true]) {
assert.deepEqual(aktionen(state, key, { aboModell: true, ticketMissing }), aktionen(state, key, { ticketMissing }), `${key} ${String(state)}`)
}
}
}
})
test('aktionen: jede angebotene Aktion hat Beschriftung und Bestätigungstext (alle Funktionen, Zustände und Optionen, auch im Abo-Modell)', () => {
for (const f of FUNKTIONEN) {
for (const state of ['off', 'ordered', 'setup', 'active', 'disabled', undefined]) {
for (const aboModell of [false, true]) {
for (const ticketMissing of [false, true]) {
for (const hindernis of [null, 'zeiten_fehlen']) {
for (const action of aktionen(state, f.key, { aboModell, ticketMissing, hindernis })) {
assert.ok(AKTION_LABEL[action], `${f.key} ${String(state)}: Beschriftung ${action}`)
assert.ok(f.bestaetigen[action], `${f.key} ${String(state)}: Text ${action}`)
}
}
}
}
}
}
})
test('aktionen: Terminplaner – bestellt nur Abschalten (kein Aktivieren ohne Zahlung), Einrichtung mit Abnahme', () => {
assert.deepEqual(aktionen('off', 'termine'), ['activate'])
assert.deepEqual(aktionen('ordered', 'termine'), ['disable'], 'Aktivieren würde eine spätere Zahlung im Portal verschlucken')
@@ -837,3 +910,111 @@ test('Buchungskalender: textformNoetig, Label, Vorlage und Links aus dem Feld un
assert.equal(schlecht.vorlage, '')
assert.equal(textformBereit({ confirmed: true, date: '2026-10-07', channel: 'email', today: '2026-10-07' }, ukFeld()), true)
})
// Kein Preis, Abrechnung beim Aktivieren (aus dem Abo / im Projekt enthalten) und Fehlertext bei fehlendem Abo
test('Kein Text der Funktionen nennt einen Preis', () => {
for (const f of FUNKTIONEN) for (const t of Object.values(f.bestaetigen)) assert.doesNotMatch(t, /€|\bEuro\b/, f.key)
})
test('ABRECHNUNG_OPTIONEN und fragtAbrechnung: nur Aktivieren, nicht nach Team-Sperre', () => {
assert.deepEqual(ABRECHNUNG_OPTIONEN, [{ value: 'abo', label: 'Aus dem Abo' }, { value: 'projekt', label: 'Im Projekt enthalten' }])
for (const key of ['news', 'termine', 'shop', 'zeiten', 'unterkunft']) {
assert.equal(fragtAbrechnung(key, 'activate', { state: 'off' }), true, key)
assert.equal(fragtAbrechnung(key, 'activate', undefined), true, key)
assert.equal(fragtAbrechnung(key, 'activate', { state: 'disabled', disabledReason: 'abo_ende' }), true, key)
assert.equal(fragtAbrechnung(key, 'activate', { state: 'disabled', disabledReason: 'team' }), false, key)
for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(fragtAbrechnung(key, action, { state: 'setup' }), false, `${key} ${action}`)
}
assert.equal(fragtAbrechnung('analyse', 'activate', { state: 'off' }), false)
})
test('ABRECHNUNG_HINWEIS: erklärt beide Wege ohne Preis', () => {
assert.equal(
ABRECHNUNG_HINWEIS,
'Aus dem Abo: Die Einrichtungszeit geht aufs Stundenkonto des Kunden. Im Projekt enthalten: Sie zählt nicht aufs Stundenkonto und läuft über die Projektkosten.'
)
assert.doesNotMatch(ABRECHNUNG_HINWEIS, /€|\bEuro\b/)
})
test('abrechnungBereit: ohne Frage immer bereit, mit Frage nur mit abo oder projekt', () => {
const b = (o) => abrechnungBereit({ key: 'news', action: 'activate', feature: { state: 'off' }, abrechnung: '', ...o })
assert.equal(b({}), false); assert.equal(b({ abrechnung: 'gratis' }), false); assert.equal(b({ abrechnung: undefined }), false)
assert.equal(b({ abrechnung: 'abo' }), true); assert.equal(b({ abrechnung: 'projekt' }), true)
assert.equal(b({ action: 'disable' }), true)
assert.equal(b({ feature: { state: 'disabled', disabledReason: 'team' } }), true)
})
test('featureBody trägt abrechnung nur beim Aktivieren und nur mit gültigem Wert', () => {
assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', abrechnung: 'projekt' }), { projectId: 'p1', action: 'activate', note: '', abrechnung: 'projekt' })
assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', abrechnung: 'gratis' }), { projectId: 'p1', action: 'activate', note: '' })
assert.deepEqual(featureBody({ projectId: 'p1', action: 'disable', note: 'x', abrechnung: 'abo' }), { projectId: 'p1', action: 'disable', note: 'x' })
assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', textformDate: '2026-10-06', textformChannel: 'chat', abrechnung: 'abo' }), { projectId: 'p1', action: 'activate', note: '', textformDate: '2026-10-06', textformChannel: 'chat', abrechnung: 'abo' })
})
test('aboFehltText und featureFehlerText: jeder Grund, unbekannter Grund, andere Fehler', () => {
const fehler = (grund, code = 'abo_fehlt') => Object.assign(new Error('Text des Portals'), { status: 409, code, grund })
assert.equal(featureFehlerText(fehler('kein_abo'), 'news'), 'News: Der Kunde hat kein laufendes Abo. Die Funktion läuft nur mit passendem Abo.')
assert.equal(featureFehlerText(fehler('falsches_abo'), 'termine'), 'Terminplaner: Das Hosting-Abo des Kunden enthält diese Funktion nicht. Sie ist im Wartungs-Abo enthalten.')
assert.equal(featureFehlerText(fehler('gekuendigt'), 'shop'), 'Webshop: Das Abo des Kunden ist gekündigt. Eingeschaltet wird nicht, solange die Kündigung vorgemerkt ist.')
assert.equal(featureFehlerText(fehler('nicht_aktiv'), 'zeiten'), 'Öffnungszeiten live: Das Abo des Kunden läuft nicht (Status nicht aktiv). Bitte zuerst das Abo klären.')
assert.equal(featureFehlerText(fehler('unbekannt'), 'unterkunft'), 'Buchungskalender: Das passende Abo fehlt.')
assert.equal(featureFehlerText(fehler(undefined), 'news'), 'News: Das passende Abo fehlt.')
assert.equal(featureFehlerText(fehler('kein_abo', 'andere'), 'news'), 'Text des Portals')
assert.equal(featureFehlerText(null, 'news'), 'Speichern fehlgeschlagen.')
})
test('aboFehltText: beginnt_spaeter nennt den Berliner Tag des Beginns, ohne brauchbares Datum bleibt der allgemeine Satz', () => {
assert.equal(aboFehltText('beginnt_spaeter', 'termine', '2026-11-03T08:00:00.000Z'), 'Terminplaner: Das Abo beginnt am 03.11.2026. Ab dann kann die Funktion eingeschaltet werden.')
// Berliner Kalendertag, nicht UTC: 23:30 UTC am 3.11. ist in Berlin schon der 4.11.
assert.equal(aboFehltText('beginnt_spaeter', 'news', '2026-11-03T23:30:00.000Z'), 'News: Das Abo beginnt am 04.11.2026. Ab dann kann die Funktion eingeschaltet werden.')
for (const ab of [undefined, null, '', 'kein Datum', 5, {}]) assert.equal(aboFehltText('beginnt_spaeter', 'news', ab), 'News: Das passende Abo fehlt.', String(ab))
// das Datum gehört nur zu beginnt_spaeter
for (const grund of ['kein_abo', 'falsches_abo', 'gekuendigt', 'nicht_aktiv', 'unbekannt']) {
assert.equal(aboFehltText(grund, 'news', '2026-11-03T08:00:00.000Z'), aboFehltText(grund, 'news'), grund)
}
})
test('featureFehlerText: beginnt_spaeter trägt das Datum des Fehlers, abgeschaltet und bereits_eingeschaltet haben eigene Texte fürs Team', () => {
const fehler = (code, extra = {}) => Object.assign(new Error('Text des Portals (in der Du-Form des Kunden)'), { status: 409, code, ...extra })
assert.equal(
featureFehlerText(fehler('abo_fehlt', { grund: 'beginnt_spaeter', ab: '2026-11-03T08:00:00.000Z' }), 'shop'),
'Webshop: Das Abo beginnt am 03.11.2026. Ab dann kann die Funktion eingeschaltet werden.'
)
assert.equal(featureFehlerText(fehler('abo_fehlt', { grund: 'beginnt_spaeter' }), 'shop'), 'Webshop: Das passende Abo fehlt.')
assert.equal(
featureFehlerText(fehler('abgeschaltet'), 'news'),
'News: Die Funktion wurde für dieses Projekt abgeschaltet (der Grund steht in der Zeile). Sie lässt sich hier nicht wieder einschalten.'
)
assert.equal(
featureFehlerText(fehler('bereits_eingeschaltet'), 'zeiten'),
'Öffnungszeiten live: Die Funktion ist für dieses Projekt schon eingeschaltet. Bitte die Seite neu laden.'
)
assert.equal(featureFehlerText(fehler('abgeschaltet'), 'unbekannt'), 'unbekannt: Die Funktion wurde für dieses Projekt abgeschaltet (der Grund steht in der Zeile). Sie lässt sich hier nicht wieder einschalten.')
// nie der Kundentext des Portals ("du", "Schreib uns im Chat"), und kein Preis
for (const code of ['abgeschaltet', 'bereits_eingeschaltet']) {
const text = featureFehlerText(fehler(code), 'termine')
assert.doesNotMatch(text, /\bdu\b|\bdein|Schreib|Portal|€/i, code)
}
// ein anderer Code oder ein Fehler ohne Code zeigt wie bisher die Meldung des Portals
assert.equal(featureFehlerText(fehler('bestellung_laeuft'), 'news'), 'Text des Portals (in der Du-Form des Kunden)')
assert.equal(featureFehlerText(Object.assign(new Error('Netz weg'), { status: 0 }), 'news'), 'Netz weg')
})
test('speicherStatus: warnung "abo_fehlt" (Aufbauphase, noch kein Abo) ist eine eigene Warnung und wird nicht mit der K16-Warnung verwechselt', () => {
const aufbau = { projects: [], warnung: 'abo_fehlt', grund: 'kein_abo' }
for (const key of [undefined, 'news', 'termine', 'shop', 'zeiten', 'unterkunft']) {
assert.deepEqual(speicherStatus(aufbau, key), { ok: false, text: ABO_FEHLT_WARNUNG }, String(key))
}
assert.equal(ABO_FEHLT_WARNUNG, 'Eingeschaltet. Achtung: Diese Funktion läuft nur mit Wartungs-Abo. Bitte das Abo vor dem Livegang vereinbaren.')
assert.doesNotMatch(ABO_FEHLT_WARNUNG, /K16|AVV|Unterlagen|€/)
assert.notEqual(ABO_FEHLT_WARNUNG, K16_WARNUNG)
assert.notEqual(ABO_FEHLT_WARNUNG, K16_WARNUNG_OHNE_UNTERLAGEN)
// die alte boolesche Warnung (K16 ging nicht raus) behält ihren Text, mit und ohne Grund
assert.deepEqual(speicherStatus({ projects: [], warnung: true }, 'termine'), { ok: false, text: K16_WARNUNG })
assert.deepEqual(speicherStatus({ projects: [], warnung: true }, 'zeiten'), { ok: false, text: K16_WARNUNG_OHNE_UNTERLAGEN })
assert.deepEqual(speicherStatus({ projects: [], warnung: true, grund: 'kein_abo' }, 'shop'), { ok: false, text: K16_WARNUNG })
// ohne Warnung (oder mit einem Wert, den es nicht gibt) bleibt es bei "Gespeichert."
for (const antwort of [{ projects: [] }, { warnung: false }, { warnung: 'unbekannt' }, { warnung: 'abo_fehlt_nicht' }, undefined, null]) {
assert.deepEqual(speicherStatus(antwort, 'news'), { ok: true, text: 'Gespeichert.' }, JSON.stringify(antwort))
}
})
+19
View File
@@ -4,6 +4,8 @@ import { loadModule } from './load.mjs'
const {
KUNDENARTEN, KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartLabel, kundenartAenderung,
KUNDENART_AUSWAHL, KUNDENART_FEHLT_FEHLER, KUNDENART_AUSWAHL_HINWEIS, KUNDENART_HOCHSTUFEN_HINWEIS,
kundenartGesetzt, kundenartPruefen,
STEUERNUMMER_MAX, STEUERNUMMER_HINWEIS, steuernummerAenderung, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt,
} = await loadModule('src/lib/kundenFelder.js')
@@ -77,3 +79,20 @@ test('steuernummerFehlt: Hinweis nur, wenn die Einstellungen da sind und die Ste
// noch nicht geladen oder nicht ladbar: es wird nichts behauptet
for (const unbekannt of [undefined, null, 'x', 5, []]) assert.equal(steuernummerFehlt(unbekannt), false, String(unbekannt))
})
test('KUNDENART_AUSWAHL: zwei Einträge ohne den Leer-Eintrag', () => {
assert.deepEqual(KUNDENART_AUSWAHL, [{ value: 'unternehmen', label: 'Unternehmen' }, { value: 'privat', label: 'Privatperson' }])
})
test('kundenartGesetzt und kundenartPruefen: nur exakt unternehmen oder privat', () => {
for (const ok of ['unternehmen', 'privat']) { assert.equal(kundenartGesetzt(ok), true); assert.equal(kundenartPruefen(ok), null) }
for (const bad of ['', ' ', 'Unternehmen', 'unternehmen ', ' privat', 'firma', null, undefined, 5, {}]) {
assert.equal(kundenartGesetzt(bad), false, String(bad))
assert.equal(kundenartPruefen(bad), 'Bitte die Kundenart wählen.', String(bad))
}
})
test('Texte der Kundenart sind festgelegt', () => {
assert.equal(KUNDENART_AUSWAHL_HINWEIS, 'Unternehmen bestellt als Betrieb, Privatperson als Verbraucher mit Widerrufsrecht. Das Portal fragt den Kunden nicht danach.')
assert.equal(KUNDENART_HOCHSTUFEN_HINWEIS, 'Bitte zuerst die Kundenart festlegen (unter „Rechnungsdaten“), dann „Zum festen Kunden machen“.')
})
+37
View File
@@ -0,0 +1,37 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { EINRICHTUNG_ABRECHNUNGEN, einrichtungAbrechnung, countsToPlanVorgabe, countsToPlanWert, nachtragPlanFelder } = await loadModule('src/lib/planVorgabe.js')
const abo = { einrichtungAbrechnung: 'abo' }, projekt = { einrichtungAbrechnung: 'projekt' }
test('einrichtungAbrechnung: nur exakt abo oder projekt', () => {
assert.deepEqual(EINRICHTUNG_ABRECHNUNGEN, ['abo', 'projekt'])
assert.deepEqual([abo, projekt, {}, null, undefined, { einrichtungAbrechnung: 'Abo' }, { einrichtungAbrechnung: 5 }].map(einrichtungAbrechnung), ['abo', 'projekt', '', '', '', '', ''])
})
test('countsToPlanVorgabe: nur Abo-Einrichtung mit erlaubten Stunden', () => {
assert.equal(countsToPlanVorgabe(abo, true), true)
for (const [t, ok] of [[abo, false], [abo, undefined], [projekt, true], [{}, true], [null, true]]) assert.equal(countsToPlanVorgabe(t, ok), false)
})
test('countsToPlanWert: Vorgabe, späte Antwort des Abo-Status, Handwahl, Kommentar', () => {
const w = (o) => countsToPlanWert({ gewaehlt: null, ticket: abo, aboHoursAllowed: true, isComment: false, ...o })
assert.equal(w({}), true) // unberührt: Vorgabe
assert.equal(w({ aboHoursAllowed: false }), false) // Abo-Status lädt noch
assert.equal(w({ aboHoursAllowed: true }), true) // …und kommt danach: die Vorgabe greift trotzdem
assert.equal(w({ gewaehlt: false }), false) // von Hand abgehakt bleibt aus
assert.equal(w({ gewaehlt: true, ticket: projekt }), true) // von Hand anhaken ist immer möglich
assert.equal(w({ gewaehlt: true, aboHoursAllowed: false }), false)
assert.equal(w({ ticket: projekt }), false)
assert.equal(w({ ticket: {} }), false)
for (const gewaehlt of [null, true, false]) assert.equal(w({ gewaehlt, isComment: true }), false)
})
test('nachtragPlanFelder: nur das erste Nachtragen einer Git-Zeit auf einer Abo-Einrichtung', () => {
const git = { serviceType: 'GIT', isComment: true, startTime: '', totalTime: 0, countsToPlan: false }
const f = (o) => nachtragPlanFelder({ worksheet: git, ticket: abo, aboHoursAllowed: true, ...o })
assert.deepEqual(f({}), { countsToPlan: true })
assert.deepEqual(f({ ticket: projekt }), {})
assert.deepEqual(f({ ticket: {} }), {})
assert.deepEqual(f({ aboHoursAllowed: false }), {})
assert.deepEqual(f({ worksheet: { ...git, startTime: '0900', totalTime: 45 } }), {}) // Zeit ist schon eingetragen: Entscheidung bleibt
assert.deepEqual(f({ worksheet: { ...git, countsToPlan: true } }), {})
assert.deepEqual(f({ worksheet: { serviceType: 'Remote', totalTime: 0, startTime: '' } }), {})
})
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { stundenkontoAnsicht, einrichtungText } = await loadModule('src/lib/stundenkontoView.js')
test('Vorschuss ohne Guthaben (Beispiel der Spec: 2,5 Stunden)', () => {
assert.deepEqual(stundenkontoAnsicht({ produkt: 'wartung', ledger: { balanceMinutes: 0, vorschussMinutes: 150, overageMinutes: 30, nextExpiry: null } }), {
art: 'konto', hinweis: '', eintraege: [
{ key: 'guthaben', label: 'Guthaben', wert: '0 min', sub: '', ton: '' },
{ key: 'vorschuss', label: 'Vorschuss (Einrichtung)', wert: '2 h 30 min', sub: 'wird mit den nächsten Gutschriften verrechnet', ton: 'warn' },
{ key: 'mehraufwand', label: 'Mehraufwand', wert: '30 min', sub: '', ton: 'warn' },
] })
})
test('Guthaben mit Verfall; Vorschuss und Mehraufwand fehlen bei 0', () => {
const a = stundenkontoAnsicht({ produkt: 'wartung', ledger: { balanceMinutes: 240, vorschussMinutes: 0, overageMinutes: 0, nextExpiry: { minutes: 60, at: '2027-08-01T12:00:00.000Z' } } })
assert.deepEqual(a.eintraege.map((e) => [e.key, e.wert, e.sub]), [['guthaben', '4 h', ''], ['verfall', '1 h', 'am 01.08.2027']])
})
test('Hosting, kein Abo, unbrauchbare Antworten und Minuten', () => {
assert.deepEqual(stundenkontoAnsicht({ produkt: 'hosting', ledger: null }), { art: 'hosting', hinweis: 'Hosting-Abo: kein Stundenkonto.', eintraege: [] })
assert.deepEqual(stundenkontoAnsicht({ produkt: null, ledger: null }), { art: 'keins', hinweis: 'Kein Abo: kein Stundenkonto.', eintraege: [] })
for (const x of [null, undefined, 'x', 5, []]) assert.equal(stundenkontoAnsicht(x), null)
const k = stundenkontoAnsicht({ produkt: 'wartung', ledger: { balanceMinutes: 85, vorschussMinutes: -5, overageMinutes: 'abc', nextExpiry: { minutes: 0, at: 'kaputt' } } })
assert.deepEqual(k.eintraege.map((e) => [e.key, e.wert]), [['guthaben', '1 h 25 min']])
})
test('Vorschuss bei beendetem Abo (active false): verfällt, wird nicht berechnet; sonst wie bisher', () => {
const ledger = { balanceMinutes: 0, vorschussMinutes: 90, overageMinutes: 30, nextExpiry: null }
const vorschuss = (antwort) => stundenkontoAnsicht(antwort).eintraege.find((e) => e.key === 'vorschuss')
assert.deepEqual(vorschuss({ produkt: 'wartung', active: false, ledger }), { key: 'vorschuss', label: 'Vorschuss (Einrichtung)', wert: '1 h 30 min', sub: 'verfällt, wird nicht berechnet', ton: 'warn' })
const verrechnet = 'wird mit den nächsten Gutschriften verrechnet'
assert.equal(vorschuss({ produkt: 'wartung', active: true, ledger }).sub, verrechnet)
// Feld fehlt (altes Portal) oder ist kein boolean false: Verhalten wie bisher
for (const active of [undefined, null, 0, '', 'false', 'nein', [], {}]) assert.equal(vorschuss({ produkt: 'wartung', active, ledger }).sub, verrechnet, String(active))
assert.equal(vorschuss({ produkt: 'wartung', ledger }).sub, verrechnet)
})
test('beendetes Abo ändert nur den Satz zum Vorschuss: Guthaben, Mehraufwand und Verfall bleiben, ohne Vorschuss kein Eintrag', () => {
const ledger = { balanceMinutes: 60, vorschussMinutes: 90, overageMinutes: 30, nextExpiry: { minutes: 60, at: '2027-08-01T12:00:00.000Z' } }
const aktiv = stundenkontoAnsicht({ produkt: 'wartung', active: true, ledger })
const beendet = stundenkontoAnsicht({ produkt: 'wartung', active: false, ledger })
assert.deepEqual(beendet.eintraege.map(({ key, wert, ton }) => [key, wert, ton]), aktiv.eintraege.map(({ key, wert, ton }) => [key, wert, ton]))
assert.deepEqual(beendet.eintraege.map((e) => e.sub), ['', 'verfällt, wird nicht berechnet', '', 'am 01.08.2027'])
const ohne = stundenkontoAnsicht({ produkt: 'wartung', active: false, ledger: { ...ledger, vorschussMinutes: 0 } })
assert.deepEqual(ohne.eintraege.map((e) => e.key), ['guthaben', 'mehraufwand', 'verfall'])
// Hosting und kein Abo: active spielt keine Rolle
assert.deepEqual(stundenkontoAnsicht({ produkt: 'hosting', active: false, ledger: null }), { art: 'hosting', hinweis: 'Hosting-Abo: kein Stundenkonto.', eintraege: [] })
assert.deepEqual(stundenkontoAnsicht({ produkt: null, active: false, ledger: null }), { art: 'keins', hinweis: 'Kein Abo: kein Stundenkonto.', eintraege: [] })
})
test('einrichtungText', () => {
assert.equal(einrichtungText({ einrichtungAbrechnung: 'abo' }), 'Einrichtung aus dem Abo: Die Arbeitszeit geht aufs Stundenkonto des Kunden, auch wenn das Guthaben nicht reicht.')
assert.equal(einrichtungText({ einrichtungAbrechnung: 'projekt' }), 'Einrichtung im Projekt enthalten: Die Arbeitszeit zählt nicht aufs Abo-Stundenkonto.')
assert.equal(einrichtungText({}), '')
})
+31 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
const { createWorksheetFlow, acquisitionOutcome } = await loadModule('src/lib/worksheetFlow.js')
const { createWorksheetFlow, acquisitionOutcome, KUNDENART_ZUGESAGT_HINWEIS } = await loadModule('src/lib/worksheetFlow.js')
async function run(ticket, newStatus, over = {}) {
const calls = { ticket: [], customer: [] }
const result = await createWorksheetFlow({
@@ -10,6 +10,7 @@ async function run(ticket, newStatus, over = {}) {
createWorksheet: async () => over.result ?? { success: true },
updateTicket: async (id, data) => { calls.ticket.push([id, data]) },
updateCustomer: async (id, data) => { calls.customer.push([id, data]); if (over.customerFails) throw new Error('weg') },
getCustomer: 'getCustomer' in over ? over.getCustomer : async () => ({ kundenart: 'unternehmen' }),
now: () => new Date('2026-10-05T10:00:00Z'),
})
return { result, calls }
@@ -38,3 +39,32 @@ test('Fehlschlag und Kundenfehler', async () => {
assert.equal((await run(akq, 'Zugesagt', { customerFails: true })).result.success, true)
assert.equal(acquisitionOutcome({ type: 'Akquise', customerId: 'c' }, 'Zugesagt'), 'customer')
})
test('Zugesagt mit gesetzter Kundenart (unternehmen, privat): Kunde fest, kein Hinweis', async () => {
for (const kundenart of ['unternehmen', 'privat']) {
const { calls, result } = await run(akq, 'Zugesagt', { getCustomer: async () => ({ kundenart }) })
assert.deepEqual(calls.customer, [['c1', { customerStatus: 'customer', updatedAt: '2026-10-05T10:00:00.000Z' }]], kundenart)
assert.equal(result.hinweis, undefined)
}
})
test('Zugesagt ohne gültige Kundenart: Blatt und Ticketstatus bleiben, der Lead bleibt Lead, Hinweis', async () => {
for (const kundenart of [undefined, '', 'Unternehmen', 'firma', ' privat']) {
const { calls, result } = await run(akq, 'Zugesagt', { getCustomer: async () => ({ kundenart }) })
assert.deepEqual(calls, { ticket: [['t1', { status: 'Zugesagt' }]], customer: [] }, String(kundenart))
assert.equal(result.success, true)
assert.equal(result.hinweis, KUNDENART_ZUGESAGT_HINWEIS)
}
})
test('Kunde nicht ladbar, null oder ohne getCustomer: wie ohne Kundenart', async () => {
for (const getCustomer of [async () => { throw new Error('weg') }, async () => null, undefined]) {
const { calls, result } = await run(akq, 'Zugesagt', { getCustomer })
assert.deepEqual(calls.customer, [])
assert.equal(result.hinweis, KUNDENART_ZUGESAGT_HINWEIS)
}
})
test('Abgesagt und andere Ergebnisse brauchen keine Kundenart und laden den Kunden nicht', async () => {
let geladen = 0
const getCustomer = async () => { geladen += 1; return {} }
assert.deepEqual((await run(akq, 'Abgesagt', { getCustomer })).calls.customer, [['c1', { customerStatus: 'lost', updatedAt: '2026-10-05T10:00:00.000Z' }]])
await run(akq, 'In Kontakt', { getCustomer })
assert.equal(geladen, 0)
})