Compare commits
12
Commits
0112c77afc
...
f11fd0bb25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11fd0bb25 | ||
|
|
8685411a93 | ||
|
|
1742fb254d | ||
|
|
06166221ec | ||
|
|
8f75276c54 | ||
|
|
dce7c3e53a | ||
|
|
f0d39f2eee | ||
|
|
5e59ad44bd | ||
|
|
58bb956645 | ||
|
|
463aaf1066 | ||
|
|
cbf4b265e0 | ||
|
|
45b2b145b0 |
No files matched your search
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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) ---
|
||||
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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 } : {}
|
||||
}
|
||||
@@ -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
@@ -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`)
|
||||
@@ -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() })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
@@ -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“.')
|
||||
})
|
||||
@@ -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: '' } }), {})
|
||||
})
|
||||
@@ -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({}), '')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user