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 { roundUpToStep, isValidStep } from '../lib/timeSteps'
|
||||||
import { validateSupporters } from '../lib/support'
|
import { validateSupporters } from '../lib/support'
|
||||||
import SupportersEditor from './SupportersEditor'
|
import SupportersEditor from './SupportersEditor'
|
||||||
|
import { countsToPlanWert } from '../lib/planVorgabe'
|
||||||
|
|
||||||
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
|
// Akquise-Tickets: Ergebnis des Kundenkontakts (statt technischer Status)
|
||||||
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
|
const ACQUISITION_STATUS = ['In Kontakt', 'Zugesagt', 'Abgesagt']
|
||||||
@@ -31,7 +32,7 @@ const initialForm = (workorder, newStatus) => ({
|
|||||||
endTime: '',
|
endTime: '',
|
||||||
details: '',
|
details: '',
|
||||||
isComment: false,
|
isComment: false,
|
||||||
countsToPlan: false,
|
countsToPlan: null, // null = unberührt: dann gilt die Abo-Vorgabe des Tickets (planVorgabe.js)
|
||||||
supporters: []
|
supporters: []
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -50,6 +51,13 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
: (workorder?.status || 'Open')
|
: (workorder?.status || 'Open')
|
||||||
|
|
||||||
const [formData, setFormData] = useState(() => initialForm(workorder, defaultStatus))
|
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 [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -130,7 +138,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
endTime: timeToWoms(formData.endTime),
|
endTime: timeToWoms(formData.endTime),
|
||||||
details: formData.details,
|
details: formData.details,
|
||||||
isComment: formData.isComment,
|
isComment: formData.isComment,
|
||||||
countsToPlan: aboHoursAllowed && !formData.isComment && formData.countsToPlan,
|
countsToPlan,
|
||||||
employeeShort: user?.prefs?.shortCode || '', // Aus User-Preferences
|
employeeShort: user?.prefs?.shortCode || '', // Aus User-Preferences
|
||||||
supporters
|
supporters
|
||||||
}
|
}
|
||||||
@@ -193,7 +201,7 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
|
|||||||
<Field
|
<Field
|
||||||
label={isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}
|
label={isAcquisition ? 'Akquise-Ergebnis' : 'Neuer Status'}
|
||||||
htmlFor="worksheet-new-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
|
<select
|
||||||
id="worksheet-new-status"
|
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">
|
<label className="check" title="Zeit wird vom Stundenguthaben des Wartungs-Abos abgezogen und im Kundenportal angezeigt">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.countsToPlan && !formData.isComment}
|
checked={countsToPlan}
|
||||||
disabled={formData.isComment}
|
disabled={formData.isComment}
|
||||||
onChange={(e) => handleChange('countsToPlan', e.target.checked)}
|
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 { getCustomerAboStatus } from '../../lib/teamApi'
|
||||||
import { fmtDate, productLabelOf } from '../../lib/format'
|
import { fmtDate, productLabelOf } from '../../lib/format'
|
||||||
import { storageLabel } from '../../lib/storageFormat'
|
import { storageLabel } from '../../lib/storageFormat'
|
||||||
|
import StundenkontoLeiste from './StundenkontoLeiste'
|
||||||
|
|
||||||
// Abo-Status eines Kunden (Reiter "Abo & Angebote"): Appwrite-Spiegel des Stripe-Abos, fuer alle Mitarbeiter
|
// Abo-Status eines Kunden (Reiter "Abo & Angebote"): Appwrite-Spiegel des Stripe-Abos, fuer alle Mitarbeiter
|
||||||
export default function AboStatusCard({ customerId }) {
|
export default function AboStatusCard({ customerId }) {
|
||||||
@@ -19,13 +20,16 @@ export default function AboStatusCard({ customerId }) {
|
|||||||
return () => { alive = false }
|
return () => { alive = false }
|
||||||
}, [customerId])
|
}, [customerId])
|
||||||
|
|
||||||
|
// Nur ein aktives Wartungs-Abo hat ein Stundenkonto (das Hosting-Abo nicht)
|
||||||
|
const mitKonto = Boolean(abo && abo.productKey === 'wartung' && abo.active)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Abo">
|
<Card title="Abo">
|
||||||
{error && <div className="msg err" role="alert">{error}</div>}
|
{error && <div className="msg err" role="alert">{error}</div>}
|
||||||
{!abo && !error && <div className="loading">Wird geladen …</div>}
|
{!abo && !error && <div className="loading">Wird geladen …</div>}
|
||||||
{abo && !abo.status && <p className="muted">kein Abo</p>}
|
{abo && !abo.status && <p className="muted">kein Abo</p>}
|
||||||
{abo && abo.status && (
|
{abo && abo.status && (
|
||||||
<dl className="kv">
|
<dl className={mitKonto ? 'kv mb-2' : 'kv'}>
|
||||||
<dt>Abo</dt>
|
<dt>Abo</dt>
|
||||||
<dd className="flex items-center gap-2 wrap">
|
<dd className="flex items-center gap-2 wrap">
|
||||||
{productLabelOf(abo.productKey)}
|
{productLabelOf(abo.productKey)}
|
||||||
@@ -37,6 +41,7 @@ export default function AboStatusCard({ customerId }) {
|
|||||||
<dt>Zusatzspeicher</dt><dd>{storageLabel(abo.storageUnits)}</dd>
|
<dt>Zusatzspeicher</dt><dd>{storageLabel(abo.storageUnits)}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
|
{mitKonto && <StundenkontoLeiste customerId={customerId} />}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,10 @@ import { Card, Badge, Button, EmptyState } from '../ui'
|
|||||||
import { copyText } from '../CopyableCredential'
|
import { copyText } from '../CopyableCredential'
|
||||||
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage, getUnterkunft, entscheideUnterkunftAnfrage } from '../../lib/teamApi'
|
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage, getUnterkunft, entscheideUnterkunftAnfrage } from '../../lib/teamApi'
|
||||||
import {
|
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,
|
hindernisText, panelModell, anleitungOffen, textformNoetig, textformAnsicht, textformBereit, speicherStatus,
|
||||||
anfrageZeilen, anfrageStatus, stripeText, stripeTon, shopHatEintrag, begruendungOk, BEGRUENDUNG_MAX, entscheidungStatus,
|
anfrageZeilen, anfrageStatus, stripeText, stripeTon, shopHatEintrag, begruendungOk, BEGRUENDUNG_MAX, entscheidungStatus,
|
||||||
|
ABRECHNUNG_OPTIONEN, ABRECHNUNG_HINWEIS, fragtAbrechnung, abrechnungBereit, featureFehlerText,
|
||||||
} from '../../lib/funktionenView'
|
} from '../../lib/funktionenView'
|
||||||
import { FUNKTION_CHANNELS, channelLabel } from '../../lib/analyseView'
|
import { FUNKTION_CHANNELS, channelLabel } from '../../lib/analyseView'
|
||||||
import { berlinYmd, fmtDateTime } from '../../lib/format'
|
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: '' }
|
const LEERE_TEXTFORM = { confirmed: false, date: '', channel: '' }
|
||||||
|
|
||||||
export default function CustomerFunktionen({ customerId }) {
|
export default function CustomerFunktionen({ customerId }) {
|
||||||
@@ -273,6 +290,7 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [confirm, setConfirm] = useState(null) // { projectId, key, action }
|
const [confirm, setConfirm] = useState(null) // { projectId, key, action }
|
||||||
const [note, setNote] = useState('')
|
const [note, setNote] = useState('')
|
||||||
|
const [abrechnung, setAbrechnung] = useState('') // Wahl beim Aktivieren: '' (noch keine) | 'abo' | 'projekt'; nie vorbelegt
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [status, setStatus] = useState(null) // { ok, text }
|
const [status, setStatus] = useState(null) // { ok, text }
|
||||||
const [panel, setPanel] = useState({}) // projectId -> true/false (Knopf "Anleitung"); ohne Eintrag entscheidet die Adresse (?anleitung=<projectId>)
|
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)
|
setStatus(null)
|
||||||
try {
|
try {
|
||||||
const mitTextform = textformNoetig(key, action, felder[key])
|
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, {
|
const antwort = await setFeature(customerId, key, {
|
||||||
projectId, action, note: note.trim(),
|
projectId, action, note: note.trim(),
|
||||||
...(mitTextform ? { textformDate: textform.date, textformChannel: textform.channel } : {}),
|
...(mitTextform ? { textformDate: textform.date, textformChannel: textform.channel } : {}),
|
||||||
|
...(mitAbrechnung ? { abrechnung } : {}),
|
||||||
})
|
})
|
||||||
// Die Antwort enthaelt das ganze Projekt; uebernommen wird nur die geaenderte Funktion
|
// Die Antwort enthaelt das ganze Projekt; uebernommen wird nur die geaenderte Funktion
|
||||||
const updated = antwort?.projects?.[0]
|
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))
|
setStatus(key === 'zeiten' && action === 'disable' ? abschaltMeldung(antwort?.ersatzTicket) : speicherStatus(antwort, key))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setConfirm(null)
|
setConfirm(null)
|
||||||
setStatus({ ok: false, text: err.message })
|
setStatus({ ok: false, text: featureFehlerText(err, key) })
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
// Version und Hindernis der Anleitung haengen vom Zustand ab: Panel dieser Zeile neu laden
|
// 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 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 heute = berlinYmd(new Date())
|
||||||
const panelOffen = (projectId) => (Object.prototype.hasOwnProperty.call(panel, projectId) ? panel[projectId] : anleitungOffen(window.location.search, projectId))
|
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) }))
|
const togglePanel = (projectId) => setPanel((p) => ({ ...p, [projectId]: !panelOffen(projectId) }))
|
||||||
@@ -398,7 +418,7 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
<td>
|
<td>
|
||||||
{!isConfirming && (
|
{!isConfirming && (
|
||||||
<div className="funktionen-actions">
|
<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)}>
|
<button key={action} type="button" className="link" onClick={() => ask(p.projectId, f.key, action)}>
|
||||||
{AKTION_LABEL[action]}
|
{AKTION_LABEL[action]}
|
||||||
</button>
|
</button>
|
||||||
@@ -417,8 +437,9 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
<td colSpan={4}>
|
<td colSpan={4}>
|
||||||
<div className="flex items-center gap-2 wrap">
|
<div className="flex items-center gap-2 wrap">
|
||||||
<span>{f.bestaetigen[confirm.action]}</span>
|
<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)} />
|
<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>
|
<Button type="button" onClick={() => setConfirm(null)} disabled={busy}>Abbrechen</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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
|
* 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)) {
|
if (!canEditWorksheetTimes(worksheet, currentUser, employee)) {
|
||||||
return { success: false, error: 'Nur wer das Arbeitsblatt erstellt hat, kann die Zeiten ändern.' }
|
return { success: false, error: 'Nur wer das Arbeitsblatt erstellt hat, kann die Zeiten ändern.' }
|
||||||
}
|
}
|
||||||
const { startDate, startTime, endDate, endTime, totalTime } = times
|
const { startDate, startTime, endDate, endTime, totalTime } = times
|
||||||
if (!isValidStep(totalTime)) return { success: false, error: STEP_ERROR }
|
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
|
// Unterstuetzung nur schreiben, wenn sie sich geaendert hat. Hat inzwischen jemand anderes
|
||||||
// (z. B. ueber "Ich habe unterstuetzt") etwas geaendert, nicht still ueberschreiben.
|
// (z. B. ueber "Ich habe unterstuetzt") etwas geaendert, nicht still ueberschreiben.
|
||||||
if (times.supporters) {
|
if (times.supporters) {
|
||||||
|
|||||||
+19
-6
@@ -1,5 +1,7 @@
|
|||||||
// Reine Funktionen der Kundenliste und der Kundenseite: Stufe, Filter, Zähler und Abzeichen.
|
// 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) {
|
export function customerStage(c) {
|
||||||
const s = (c?.customerStatus || '').toLowerCase()
|
const s = (c?.customerStatus || '').toLowerCase()
|
||||||
@@ -22,11 +24,21 @@ export const CUSTOMER_TABS = [
|
|||||||
{ id: 'all', label: 'Alle' },
|
{ 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.
|
* 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 s = String(search || '').toLowerCase()
|
||||||
const matchesStage = (c) => {
|
const matchesStage = (c) => {
|
||||||
const archived = isArchived(c)
|
const archived = isArchived(c)
|
||||||
@@ -38,15 +50,16 @@ export function filterCustomers(customers, { stage = 'customer', search = '' } =
|
|||||||
}
|
}
|
||||||
return (customers || []).filter((c) => {
|
return (customers || []).filter((c) => {
|
||||||
if (!matchesStage(c)) return false
|
if (!matchesStage(c)) return false
|
||||||
|
if (nurKundenartFehlt && !kundenartFehlt(c)) return false
|
||||||
if (!s) return true
|
if (!s) return true
|
||||||
return `${c.name || ''} ${c.code || ''} ${c.location || ''} ${c.email || ''} ${c.contactName || ''} ${c.phone || ''}`.toLowerCase().includes(s)
|
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). */
|
/** Zähler je Reiter, berechnet mit derselben Filterfunktion wie die Liste (beachtet Suchtext und Schalter „Kundenart fehlt“). */
|
||||||
export function stageCounts(customers, search = '') {
|
export function stageCounts(customers, search = '', { nurKundenartFehlt = false } = {}) {
|
||||||
const counts = {}
|
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
|
return counts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args)
|
|||||||
if (info.invoiceNumber) err.invoiceNumber = info.invoiceNumber
|
if (info.invoiceNumber) err.invoiceNumber = info.invoiceNumber
|
||||||
// Team-Einstellungen der Oeffnungszeiten (PATCH): code (409 ZEITEN_VERALTET) und fehler je Feld (400)
|
// Team-Einstellungen der Oeffnungszeiten (PATCH): code (409 ZEITEN_VERALTET) und fehler je Feld (400)
|
||||||
if (typeof info.code === 'string') err.code = info.code
|
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
|
if (info.fehler && typeof info.fehler === 'object') err.fehler = info.fehler
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-10
@@ -12,6 +12,7 @@ export const FUNKTIONEN = [
|
|||||||
bestaetigen: {
|
bestaetigen: {
|
||||||
activate: 'News ohne Zahlung aktivieren?',
|
activate: 'News ohne Zahlung aktivieren?',
|
||||||
disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.',
|
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',
|
label: 'Terminplaner',
|
||||||
bestaetigen: {
|
bestaetigen: {
|
||||||
activate: 'Terminplaner ohne Zahlung aktivieren?',
|
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.',
|
abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.',
|
||||||
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
|
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).
|
* 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
|
* 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').
|
* (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 (key === 'termine' || key === 'shop' || key === 'zeiten' || key === 'unterkunft') {
|
||||||
if (state === 'ordered' || state === 'active') return ['disable']
|
if (state === 'ordered' || state === 'active') return ['disable']
|
||||||
if (state === 'setup') {
|
if (state === 'setup') {
|
||||||
@@ -122,12 +126,20 @@ export function aktionen(state, key, { ticketMissing = false, hindernis = null }
|
|||||||
}
|
}
|
||||||
return ['activate']
|
return ['activate']
|
||||||
}
|
}
|
||||||
|
if (state === 'setup' && aboModell === true) return [...(ticketMissing ? ['ticket'] : []), 'disable']
|
||||||
const list = []
|
const list = []
|
||||||
if (state !== 'active') list.push('activate')
|
if (state !== 'active') list.push('activate')
|
||||||
if (['ordered', 'setup', 'active'].includes(state)) list.push('disable')
|
if (['ordered', 'setup', 'active'].includes(state)) list.push('disable')
|
||||||
return list
|
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 ''. */
|
/** Warum der Terminplaner in "setup" noch nicht freigeschaltet werden kann (Portal: feature.hindernis) -> Hinweistext; sonst ''. */
|
||||||
const HINDERNIS_TEXT = {
|
const HINDERNIS_TEXT = {
|
||||||
kein_abo: 'Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.',
|
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 })
|
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. */
|
/** Wahl beim Aktivieren: wohin die Einrichtungszeit geht (Portal: POST /funktionen/:customerId/:key, Feld abrechnung). */
|
||||||
export function featureBody({ projectId, action, note, textformDate, textformChannel }) {
|
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 }
|
const body = { projectId, action, note }
|
||||||
if (action === 'activate' && textformDate && textformChannel) {
|
if (action === 'activate' && textformDate && textformChannel) {
|
||||||
body.textformDate = textformDate
|
body.textformDate = textformDate
|
||||||
body.textformChannel = textformChannel
|
body.textformChannel = textformChannel
|
||||||
}
|
}
|
||||||
|
if (action === 'activate' && ABRECHNUNG_OPTIONEN.some((o) => o.value === abrechnung)) body.abrechnung = abrechnung
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,13 +393,21 @@ export const K16_WARNUNG_OHNE_UNTERLAGEN = 'Eingeschaltet, aber die E-Mail „be
|
|||||||
const K16_OHNE_UNTERLAGEN = ['zeiten']
|
const K16_OHNE_UNTERLAGEN = ['zeiten']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statuszeile nach dem Speichern einer Aktion: Warnung statt "Gespeichert.", wenn die Antwort warnung: true traegt. Der Wortlaut der
|
* Aufbauphase (Abo-Modell): das Team hat "Im Projekt enthalten" gewaehlt, obwohl der Kunde noch nie ein Abo hatte. Das Portal schaltet ein und
|
||||||
* Warnung folgt der Funktion (key): Terminplaner und Webshop nennen Leistungsbeschreibung und AVV, Oeffnungszeiten live keinen AVV.
|
* 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) =>
|
export const ABO_FEHLT_WARNUNG = 'Eingeschaltet. Achtung: Diese Funktion läuft nur mit Wartungs-Abo. Bitte das Abo vor dem Livegang vereinbaren.'
|
||||||
antwort?.warnung === true
|
|
||||||
? { ok: false, text: K16_OHNE_UNTERLAGEN.includes(key) ? K16_WARNUNG_OHNE_UNTERLAGEN : K16_WARNUNG }
|
/**
|
||||||
: { ok: true, text: 'Gespeichert.' }
|
* 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) ---
|
// --- 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.'
|
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 : '')
|
const text = (v) => (typeof v === 'string' ? v : '')
|
||||||
|
|
||||||
/** Beschriftung der Kundenart; alles Unbekannte heisst „Nicht festgelegt“. */
|
/** Beschriftung der Kundenart; alles Unbekannte heisst „Nicht festgelegt“. */
|
||||||
|
|||||||
+6
-2
@@ -1,5 +1,6 @@
|
|||||||
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from './appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from './appwrite'
|
||||||
import { rejectionLine, notesWithRejection } from './leadDuplicates'
|
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“). */
|
/** Pflichtfelder eines festen Kunden: Name, E-Mail und vollständige Anschrift (Voraussetzung für „Zum festen Kunden machen“). */
|
||||||
export function isInvoiceReady(c) {
|
export function isInvoiceReady(c) {
|
||||||
@@ -55,13 +56,16 @@ export async function createAcquisitionTicket(customer, user) {
|
|||||||
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ID.unique(), data)
|
return databases.createDocument(DATABASE_ID, COLLECTIONS.WORKORDERS, ID.unique(), data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legt einen Lead an (Unternehmen, E-Mail, Telefon) und erstellt das Akquise-Ticket. */
|
/** 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 }, user) {
|
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(), {
|
const customer = await databases.createDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, ID.unique(), {
|
||||||
name: company,
|
name: company,
|
||||||
companyName: company,
|
companyName: company,
|
||||||
email: email || '',
|
email: email || '',
|
||||||
phone: phone || '',
|
phone: phone || '',
|
||||||
|
kundenart,
|
||||||
customerStatus: 'lead',
|
customerStatus: 'lead',
|
||||||
updatedAt: new Date().toISOString(),
|
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] }.
|
* 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).
|
* 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 }) =>
|
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 }) })
|
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)
|
export const setNewsFeature = (customerId, body) => setFeature(customerId, 'news', body)
|
||||||
/** Einbauanleitung des Terminplaners: { stand, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis } (Aufbereitung: panelModell). */
|
/** Einbauanleitung des Terminplaners: { stand, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis } (Aufbereitung: panelModell). */
|
||||||
export const getTermineAnleitung = (customerId, projectId) => request(anleitungPfad(customerId, projectId))
|
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 } */
|
/** { productKey, active, status, storageUnits, startedAt, serviceStart, currentPeriodEnd, cancelAt } */
|
||||||
export const getCustomerAboStatus = (customerId) => request(`/customers/${enc(customerId)}/abo`)
|
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.
|
/* Folgen eines neuen Arbeitsblatts.
|
||||||
Kennt kein Appwrite: die Seite reicht die Schreibzugriffe herein. */
|
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.
|
// Akquise-Automatik: das Ergebnis im Arbeitsblatt steuert den Kundenstatus.
|
||||||
// "customer" = Zugesagt, "lost" = Abgesagt, sonst keine Aenderung.
|
// "customer" = Zugesagt, "lost" = Abgesagt, sonst keine Aenderung.
|
||||||
export function acquisitionOutcome(ticket, newStatus) {
|
export function acquisitionOutcome(ticket, newStatus) {
|
||||||
@@ -10,10 +15,21 @@ export function acquisitionOutcome(ticket, newStatus) {
|
|||||||
return null
|
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.
|
/* 1. Arbeitsblatt anlegen. 2. Bei Erfolg den Ticket-Status nachziehen, wenn er abweicht.
|
||||||
3. Bei Akquise-Tickets den Kundenstatus setzen (Fehler werden verschluckt).
|
3. Bei Akquise-Tickets den Kundenstatus setzen (Fehler werden verschluckt). "Zugesagt" stuft den Lead nur hoch,
|
||||||
Rueckgabe ist das Ergebnis von createWorksheet. */
|
wenn seine Kundenart gesetzt ist; sonst bleiben Blatt und Ticketstatus, der Lead bleibt Lead und das Ergebnis traegt
|
||||||
export async function createWorksheetFlow({ ticket, worksheetData, user, createWorksheet, updateTicket, updateCustomer, now = () => new Date() }) {
|
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)
|
const result = await createWorksheet(worksheetData, user)
|
||||||
if (!result?.success) return result
|
if (!result?.success) return result
|
||||||
|
|
||||||
@@ -23,6 +39,9 @@ export async function createWorksheetFlow({ ticket, worksheetData, user, createW
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customerStatus = acquisitionOutcome(ticket, newStatus)
|
const customerStatus = acquisitionOutcome(ticket, newStatus)
|
||||||
|
if (customerStatus === 'customer' && !(await kundenartDesKunden(getCustomer, ticket.customerId))) {
|
||||||
|
return { ...result, hinweis: KUNDENART_ZUGESAGT_HINWEIS }
|
||||||
|
}
|
||||||
if (customerStatus) {
|
if (customerStatus) {
|
||||||
try {
|
try {
|
||||||
await updateCustomer(ticket.customerId, { customerStatus, updatedAt: now().toISOString() })
|
await updateCustomer(ticket.customerId, { customerStatus, updatedAt: now().toISOString() })
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
|||||||
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
|
import { customerTabs, resolveCustomerTab } from '../lib/customerTabs'
|
||||||
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer, markLost, reopenAsLead } from '../lib/leads'
|
import { isInvoiceReady, setCustomerArchived, upgradeToCustomer, markLost, reopenAsLead } from '../lib/leads'
|
||||||
import { lastRejection } from '../lib/leadDuplicates'
|
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 { customerStage, isArchived, customerBadges, customerEyebrow, customerTitle } from '../lib/customerList'
|
||||||
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
|
import { ticketPath, ticketRef } from '../lib/ticketRoutes'
|
||||||
import { ticketNumber } from '../lib/ticketView'
|
import { ticketNumber } from '../lib/ticketView'
|
||||||
@@ -196,6 +196,7 @@ function OverviewTab({ customer, stage, onSaved }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const upgrade = async () => {
|
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 }
|
if (!ready) { setMsg({ tone: 'warn', text: 'Bitte zuerst alle Pflichtfelder (Unternehmen, E-Mail, Strasse, PLZ, Stadt) ausfuellen.' }); return }
|
||||||
setBusy(true); setMsg(null)
|
setBusy(true); setMsg(null)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { Link, useNavigate } from 'react-router-dom'
|
|||||||
import { useCustomers } from '../hooks/useCustomers'
|
import { useCustomers } from '../hooks/useCustomers'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { createLead } from '../lib/leads'
|
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 { isRowActivation } from '../lib/rowClick'
|
||||||
import { findDuplicates } from '../lib/leadDuplicates'
|
import { findDuplicates } from '../lib/leadDuplicates'
|
||||||
import DuplicateWarning from '../components/customer/DuplicateWarning'
|
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'
|
import { PageHeader, Tabs, Button, Badge, EmptyState, Field, Dialog } from '../components/ui'
|
||||||
|
|
||||||
export default function CustomersPage() {
|
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
|
// 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 [stage, setStage] = useState('customer') // customer | lead | lost | archived | all
|
||||||
const [showCreate, setShowCreate] = useState(null) // null | 'lead' | 'customer'
|
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])
|
const filtered = useMemo(() => filterCustomers(customers, { stage, search, nurKundenartFehlt: nurFehlt }), [customers, stage, search, nurFehlt])
|
||||||
// Zähler mit derselben Funktion wie die Liste (beachten den Suchtext); bis zum Laden ohne Zahl
|
// Zähler mit derselben Funktion wie die Liste (beachten Suchtext und Schalter); bis zum Laden ohne Zahl
|
||||||
const counts = useMemo(() => stageCounts(customers, search), [customers, search])
|
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] }))
|
const tabs = CUSTOMER_TABS.map((tab) => ({ id: tab.id, label: tab.label, count: loading ? undefined : counts[tab.id] }))
|
||||||
|
|
||||||
let content
|
let content
|
||||||
@@ -98,6 +101,10 @@ export default function CustomersPage() {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
{content}
|
{content}
|
||||||
@@ -138,6 +145,7 @@ function CreateLeadForm({ customers, onCancel, onDone }) {
|
|||||||
const [company, setCompany] = useState('')
|
const [company, setCompany] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [phone, setPhone] = useState('')
|
const [phone, setPhone] = useState('')
|
||||||
|
const [kundenart, setKundenart] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
// Abgleich mit allen Kunden (auch archivierten und abgesagten), damit niemand doppelt angerufen wird
|
// 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) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!company.trim()) { setError('Unternehmen erforderlich'); return }
|
if (!company.trim()) { setError('Unternehmen erforderlich'); return }
|
||||||
|
const f = kundenartPruefen(kundenart)
|
||||||
|
if (f) { setError(f); return }
|
||||||
setBusy(true); setError(null)
|
setBusy(true); setError(null)
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
setError(err.message || 'Fehler')
|
setError(err.message || 'Fehler')
|
||||||
setBusy(false)
|
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)} />
|
<input id="lead-phone" className="input" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
<Field label="Kundenart" hint={KUNDENART_AUSWAHL_HINWEIS}>
|
||||||
|
<KundenartAuswahl value={kundenart} onChange={setKundenart} disabled={busy} />
|
||||||
|
</Field>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fester Kunde mit Zugang zum Kundenportal (nur Admins; legt den Login im Portal an)
|
// Fester Kunde mit Zugang zum Kundenportal (nur Admins; legt den Login im Portal an)
|
||||||
export function CreateCustomerForm({ customers, onCancel, onCreate }) {
|
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 [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const matches = useMemo(
|
const matches = useMemo(
|
||||||
@@ -207,6 +220,8 @@ export function CreateCustomerForm({ customers, onCancel, onCreate }) {
|
|||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (form.password.length < 8) { setError('Das Portal-Passwort muss mindestens 8 Zeichen haben.'); return }
|
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)
|
setBusy(true); setError(null)
|
||||||
const r = await onCreate({ ...form, name: form.name.trim(), email: form.email.trim() })
|
const r = await onCreate({ ...form, name: form.name.trim(), email: form.email.trim() })
|
||||||
if (!r.success) {
|
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 />
|
<input id="customer-password" className="input" type="password" autoComplete="new-password" name="password" value={form.password} onChange={set('password')} required />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
<Field label="Kundenart" hint={KUNDENART_AUSWAHL_HINWEIS}>
|
||||||
|
<KundenartAuswahl value={form.kundenart} onChange={(v) => setForm((f) => ({ ...f, kundenart: v }))} disabled={busy} />
|
||||||
|
</Field>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { PageHeader, Tabs, Card, EmptyState, Button } from '../components/ui'
|
|||||||
import UrgentToggle from '../components/UrgentToggle'
|
import UrgentToggle from '../components/UrgentToggle'
|
||||||
import WorksheetSummary from '../components/WorksheetSummary'
|
import WorksheetSummary from '../components/WorksheetSummary'
|
||||||
import WorksheetList from '../components/WorksheetList'
|
import WorksheetList from '../components/WorksheetList'
|
||||||
|
import StundenkontoLeiste from '../components/customer/StundenkontoLeiste'
|
||||||
import ProjectContextPanel from '../components/ProjectContextPanel'
|
import ProjectContextPanel from '../components/ProjectContextPanel'
|
||||||
import TicketProjects from '../components/TicketProjects'
|
import TicketProjects from '../components/TicketProjects'
|
||||||
import CreateWorksheetModal from '../components/CreateWorksheetModal'
|
import CreateWorksheetModal from '../components/CreateWorksheetModal'
|
||||||
@@ -18,6 +19,9 @@ import { databases, DATABASE_ID, COLLECTIONS } from '../lib/appwrite'
|
|||||||
import { elapsedLabel } from '../lib/elapsed'
|
import { elapsedLabel } from '../lib/elapsed'
|
||||||
import { canAddOwnSupport } from '../lib/support'
|
import { canAddOwnSupport } from '../lib/support'
|
||||||
import { canEditWorksheetTimes, serviceTypeLabel } from '../lib/ticketForm'
|
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 { listBackPath, resolveTicketTab, ticketPath, ticketRef } from '../lib/ticketRoutes'
|
||||||
import { statusSelectOptions, ticketTypeLabel } from '../lib/ticketStatus'
|
import { statusSelectOptions, ticketTypeLabel } from '../lib/ticketStatus'
|
||||||
import { appointmentText, assigneeOptions, dueText, ticketNumber } from '../lib/ticketView'
|
import { appointmentText, assigneeOptions, dueText, ticketNumber } from '../lib/ticketView'
|
||||||
@@ -90,6 +94,8 @@ function TicketView({ ticket, updateWorkorder, tab }) {
|
|||||||
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
||||||
const [editTimesWorksheet, setEditTimesWorksheet] = useState(null)
|
const [editTimesWorksheet, setEditTimesWorksheet] = useState(null)
|
||||||
const [ownSupportWorksheet, setOwnSupportWorksheet] = 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
|
// 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({})
|
const [pending, setPending] = useState({})
|
||||||
|
|
||||||
@@ -109,6 +115,9 @@ function TicketView({ ticket, updateWorkorder, tab }) {
|
|||||||
tab === 'arbeitsblaetter' || showCreateWorksheet
|
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 isPending = (field) => pending[field] !== undefined
|
||||||
const shown = (field, current) => (isPending(field) ? pending[field] : current)
|
const shown = (field, current) => (isPending(field) ? pending[field] : current)
|
||||||
|
|
||||||
@@ -140,7 +149,9 @@ function TicketView({ ticket, updateWorkorder, tab }) {
|
|||||||
createWorksheet,
|
createWorksheet,
|
||||||
updateTicket: updateWorkorder,
|
updateTicket: updateWorkorder,
|
||||||
updateCustomer: (id, data) => databases.updateDocument(DATABASE_ID, COLLECTIONS.CUSTOMERS, id, data),
|
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
|
// Das neue Blatt soll sichtbar sein
|
||||||
if (result?.success && tab !== 'arbeitsblaetter') goToTab('arbeitsblaetter')
|
if (result?.success && tab !== 'arbeitsblaetter') goToTab('arbeitsblaetter')
|
||||||
return result
|
return result
|
||||||
@@ -238,6 +249,8 @@ function TicketView({ ticket, updateWorkorder, tab }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hinweis && <p className="msg warn mb-2" role="alert">{hinweis}</p>}
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ id: 'details', label: 'Details' },
|
{ id: 'details', label: 'Details' },
|
||||||
@@ -247,6 +260,15 @@ function TicketView({ ticket, updateWorkorder, tab }) {
|
|||||||
onChange={(id) => { if (id !== tab) goToTab(id) }}
|
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' && (
|
{tab === 'details' && (
|
||||||
<>
|
<>
|
||||||
<Card title="Ticket-Beschreibung">
|
<Card title="Ticket-Beschreibung">
|
||||||
@@ -301,7 +323,7 @@ function TicketView({ ticket, updateWorkorder, tab }) {
|
|||||||
worksheet={editTimesWorksheet}
|
worksheet={editTimesWorksheet}
|
||||||
currentUserId={user?.$id}
|
currentUserId={user?.$id}
|
||||||
onClose={() => setEditTimesWorksheet(null)}
|
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}
|
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 { 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 { 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); }
|
.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 {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { loadModule } from './load.mjs'
|
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')
|
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' }
|
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({ email: 'x@y.de' }), 'x@y.de')
|
||||||
assert.equal(customerTitle({}), '–')
|
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.code, undefined)
|
||||||
assert.equal(ohne.fehler, 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,
|
AKTION_LABEL,
|
||||||
funktionOf,
|
funktionOf,
|
||||||
aktionen,
|
aktionen,
|
||||||
|
aboModellFunktion,
|
||||||
mergeFunktion,
|
mergeFunktion,
|
||||||
siteKeyOf,
|
siteKeyOf,
|
||||||
abgeschaltetText,
|
abgeschaltetText,
|
||||||
@@ -28,9 +29,16 @@ const {
|
|||||||
textformAnsicht,
|
textformAnsicht,
|
||||||
textformBereit,
|
textformBereit,
|
||||||
featureBody,
|
featureBody,
|
||||||
|
ABRECHNUNG_OPTIONEN,
|
||||||
|
ABRECHNUNG_HINWEIS,
|
||||||
|
fragtAbrechnung,
|
||||||
|
abrechnungBereit,
|
||||||
|
aboFehltText,
|
||||||
|
featureFehlerText,
|
||||||
speicherStatus,
|
speicherStatus,
|
||||||
K16_WARNUNG,
|
K16_WARNUNG,
|
||||||
K16_WARNUNG_OHNE_UNTERLAGEN,
|
K16_WARNUNG_OHNE_UNTERLAGEN,
|
||||||
|
ABO_FEHLT_WARNUNG,
|
||||||
warenartLabel,
|
warenartLabel,
|
||||||
artikelzahlLabel,
|
artikelzahlLabel,
|
||||||
anfrageStatus,
|
anfrageStatus,
|
||||||
@@ -52,6 +60,7 @@ test('FUNKTIONEN: News, Terminplaner, Webshop und Öffnungszeiten live (in diese
|
|||||||
bestaetigen: {
|
bestaetigen: {
|
||||||
activate: 'News ohne Zahlung aktivieren?',
|
activate: 'News ohne Zahlung aktivieren?',
|
||||||
disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.',
|
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',
|
label: 'Terminplaner',
|
||||||
bestaetigen: {
|
bestaetigen: {
|
||||||
activate: 'Terminplaner ohne Zahlung aktivieren?',
|
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.',
|
abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.',
|
||||||
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
|
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"')
|
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
|
const text = FUNKTIONEN.find((f) => f.key === 'termine').bestaetigen.disable
|
||||||
assert.match(text, /^Terminplaner abschalten\?/)
|
assert.match(text, /^Terminplaner abschalten\?/)
|
||||||
assert.match(text, /keine Buchungen mehr möglich/, 'Buchungen stehen')
|
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, /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
|
// 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.')
|
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', () => {
|
test('aktionen: Terminplaner – bestellt nur Abschalten (kein Aktivieren ohne Zahlung), Einrichtung mit Abnahme', () => {
|
||||||
assert.deepEqual(aktionen('off', 'termine'), ['activate'])
|
assert.deepEqual(aktionen('off', 'termine'), ['activate'])
|
||||||
assert.deepEqual(aktionen('ordered', 'termine'), ['disable'], 'Aktivieren würde eine spätere Zahlung im Portal verschlucken')
|
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(schlecht.vorlage, '')
|
||||||
assert.equal(textformBereit({ confirmed: true, date: '2026-10-07', channel: 'email', today: '2026-10-07' }, ukFeld()), true)
|
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 {
|
const {
|
||||||
KUNDENARTEN, KUNDENART_OPTIONEN, KUNDENART_HINWEIS, kundenartLabel, kundenartAenderung,
|
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,
|
STEUERNUMMER_MAX, STEUERNUMMER_HINWEIS, steuernummerAenderung, STEUERNUMMER_FEHLT_HINWEIS, steuernummerFehlt,
|
||||||
} = await loadModule('src/lib/kundenFelder.js')
|
} = 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
|
// 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))
|
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 assert from 'node:assert/strict'
|
||||||
import { loadModule } from './load.mjs'
|
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 = {}) {
|
async function run(ticket, newStatus, over = {}) {
|
||||||
const calls = { ticket: [], customer: [] }
|
const calls = { ticket: [], customer: [] }
|
||||||
const result = await createWorksheetFlow({
|
const result = await createWorksheetFlow({
|
||||||
@@ -10,6 +10,7 @@ async function run(ticket, newStatus, over = {}) {
|
|||||||
createWorksheet: async () => over.result ?? { success: true },
|
createWorksheet: async () => over.result ?? { success: true },
|
||||||
updateTicket: async (id, data) => { calls.ticket.push([id, data]) },
|
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') },
|
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'),
|
now: () => new Date('2026-10-05T10:00:00Z'),
|
||||||
})
|
})
|
||||||
return { result, calls }
|
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((await run(akq, 'Zugesagt', { customerFails: true })).result.success, true)
|
||||||
assert.equal(acquisitionOutcome({ type: 'Akquise', customerId: 'c' }, 'Zugesagt'), 'customer')
|
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