Rechnungsanalyse: Termin landet im Akquise-Ticket und damit im Planboard

Der im Gespraech vereinbarte Termin wird mit einem Klick am Akquise-Ticket
eingetragen (startDate/startTime/endTime/assignedTo/assignedName) - genau die
Felder, die das WOMS-Planboard liest. Kein zweiter Datensatz, damit Termin und
Ticket nicht auseinanderlaufen koennen.

- server/services/appointments.js: Ticket des Kunden finden, Termin schreiben,
  belegte Zeiten lesen; Umrechnung ISO <-> "dd.mm.yyyy" und "hh:mm" <-> "hhmm"
- Routen GET /api/portal-admin/appointments und
  POST /api/portal-admin/customers/:id/appointment
- Bereits vergebene Zeiten sind in der Terminauswahl gesperrt ("belegt"), der
  eigene Termin bleibt waehlbar; steht am Ticket schon einer, ist er beim
  Oeffnen vorausgewaehlt
- Zeiten, die jemand von Hand in WOMS gesetzt hat, tauchen mit auf

Sicherung gegen Datenverlust: bevorzugt wird das Akquise-Ticket. Gibt es keins,
kommt nur ein anderes offenes Ticket OHNE Datum in Frage - bei Webpage- und
Projekttickets bedeutet startDate den Projektstart, den wuerden wir sonst still
ueberschreiben. Die Rueckmeldung nennt immer Nummer und Art des Tickets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-08-08 22:09:51 +00:00
parent c4a6c90306
commit ac264dc9ba
5 changed files with 345 additions and 6 deletions

View File

@@ -123,6 +123,7 @@ const STAFFEL = [{ max: 5, r: 150 }, { max: 15, r: 130 }, { max: 40, r: 115 }, {
const PEOPLE = {
justin: {
name: 'Justin', role: 'Website, Angebote, Betreuung', initial: 'J',
email: 'justin@webklar.com',
slots: {
1: ['09:00', '10:00', '11:00', '16:00', '17:00'],
2: ['09:00', '10:00', '16:00', '17:00'],
@@ -133,6 +134,7 @@ const PEOPLE = {
},
kenso: {
name: 'Kenso', role: 'Shop, Software, Automatisierung', initial: 'K',
email: 'kenso@webklar.com',
slots: { 2: ['14:00', '15:00', '18:00'], 4: ['14:00', '15:00', '18:00'], 6: ['10:00', '11:00'] },
},
}
@@ -162,6 +164,11 @@ const state = {
let customer = null
let storageKey = 'wk-kalkulation:neu'
/* Schon vergebene Termine (aus den Akquise-Tickets), damit nichts doppelt
gebucht wird. Wird beim Laden und nach jedem Speichern aktualisiert. */
let booked = []
let savedAppointment = null
/* --------------------------------------------------------------- Helfer --- */
function escapeHtml(str) {
return String(str ?? '')
@@ -179,10 +186,11 @@ function eur(n) {
return Number(n || 0).toLocaleString('de-DE', { maximumFractionDigits: 0 }) + ' €'
}
async function api(path) {
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
...options,
})
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(data.error || `Fehler ${response.status}`)
@@ -285,14 +293,31 @@ function totals() {
}
/* ---------------------------------------------------------------- Termin --- */
/* Feste Zeitfenster der Person, ergaenzt um Zeiten, die in WOMS bereits am
Ticket stehen (jemand kann dort auch von Hand einen Termin setzen). */
function slotsFor(personId, date) {
return PEOPLE[personId].slots[date.getDay()] || []
const base = PEOPLE[personId].slots[date.getDay()] || []
const key = dayKey(date)
const email = PEOPLE[personId].email
const extra = booked
.filter((a) => a.date === key && a.employeeEmail === email && a.time && !base.includes(a.time))
.map((a) => a.time)
return extra.length ? [...new Set([...base, ...extra])].sort() : base
}
function dayKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
/* Ist die Zeit beim aktuell gewaehlten Mitarbeiter schon vergeben?
'own' = der Termin dieses Kunden (der darf natuerlich stehen bleiben). */
function slotTaken(dateKey, time) {
const email = PEOPLE[state.person].email
const hit = booked.find((a) => a.date === dateKey && a.time === time && a.employeeEmail === email)
if (!hit) return null
return hit.customerId && customer && hit.customerId === customer.id ? 'own' : 'other'
}
function terminDays() {
const today = new Date()
today.setHours(0, 0, 0, 0)
@@ -300,13 +325,34 @@ function terminDays() {
for (let i = 1; i <= 14; i += 1) {
const d = new Date(today)
d.setDate(today.getDate() + i)
list.push({ key: dayKey(d), date: d, slots: slotsFor(state.person, d) })
const key = dayKey(d)
const slots = slotsFor(state.person, d)
list.push({
key,
date: d,
slots,
free: slots.filter((s) => slotTaken(key, s) !== 'other'),
})
}
return list
}
async function loadAppointments() {
const days = terminDays()
if (!days.length) return
try {
const { appointments } = await api(
`/api/portal-admin/appointments?from=${days[0].key}&to=${days[days.length - 1].key}`
)
booked = appointments || []
} catch (err) {
console.warn('Belegte Termine konnten nicht geladen werden:', err.message)
booked = []
}
}
function selectedDay() {
return terminDays().find((d) => d.key === state.day && d.slots.length) || null
return terminDays().find((d) => d.key === state.day && d.free.length) || null
}
function longDay(date) {
@@ -360,6 +406,7 @@ function summaryText() {
if (term) {
lines.push('', 'TERMIN', term)
if (state.termNote) lines.push(`Thema: ${state.termNote}`)
if (savedAppointment?.woid) lines.push(`Eingetragen in Ticket #${savedAppointment.woid}`)
}
if (state.notes) lines.push('', 'NOTIZEN', state.notes)
if (state.portalEmail || state.portalPass) {
@@ -475,7 +522,7 @@ function renderTermin() {
const days = terminDays()
el('days-label').textContent = `Freie Tage — ${PEOPLE[state.person].name}`
el('day-list').innerHTML = days.map((d) => {
const free = d.slots.length
const free = d.free.length
const on = state.day === d.key && free > 0
return `<button type="button" class="day${on ? ' on' : ''}" data-act="day" data-key="${d.key}"${free ? '' : ' disabled'}>
<span class="wd">${d.date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')}</span>
@@ -489,12 +536,35 @@ function renderTermin() {
if (day) {
el('slots-label').textContent = `Uhrzeit am ${longDay(day.date)}`
el('slot-list').innerHTML = day.slots.map((s) => {
const taken = slotTaken(day.key, s)
const on = state.slot === s
return `<button type="button" class="slot${on ? ' on' : ''}" data-act="slot" data-t="${s}" aria-pressed="${on}">${s}</button>`
const cls = ['slot', on ? 'on' : '', taken === 'own' && !on ? 'taken-own' : ''].filter(Boolean).join(' ')
const label = taken === 'other' ? `${s} · belegt` : (taken === 'own' && !on ? `${s} · dieser Kunde` : s)
return `<button type="button" class="${cls}" data-act="slot" data-t="${s}"
${taken === 'other' ? 'disabled' : ''} aria-pressed="${on}">${label}</button>`
}).join('')
}
renderTermStatus()
renderSaveButton()
}
function renderSaveButton() {
const btn = el('term-save')
const hint = el('term-save-hint')
if (!btn) return
const ready = Boolean(terminLine()) && Boolean(customer)
btn.disabled = !ready || btn.dataset.busy === '1'
if (hint.dataset.sticky === '1') return
hint.className = 'term-save-hint'
if (!customer) {
hint.textContent = 'Ohne Kunde kann kein Ticket beschrieben werden — Seite über einen Lead öffnen.'
} else if (!ready) {
hint.textContent = 'Trägt den Termin am Akquise-Ticket ein — damit steht er im Planboard.'
} else {
hint.textContent = `Trägt den Termin am Akquise-Ticket von ${customer.name} ein — damit steht er im Planboard.`
}
}
function renderTermStatus() {
@@ -538,6 +608,59 @@ function setQty(id, value) {
render()
}
/* Steht am Akquise-Ticket schon ein Termin, ist das die Wahrheit - der
Zwischenspeicher im Browser hat dann das Nachsehen. */
function adoptExistingAppointment() {
const existing = booked.find((a) => a.customerId === customer.id)
if (!existing) return
savedAppointment = existing
const personId = Object.keys(PEOPLE).find((id) => PEOPLE[id].email === existing.employeeEmail)
if (!personId) return
state.wantTermin = true
state.person = personId
state.day = existing.date
state.slot = existing.time
}
function clearSaveHint() {
const hint = el('term-save-hint')
if (hint) delete hint.dataset.sticky
}
/* Termin am Akquise-Ticket eintragen -> erscheint im WOMS-Planboard. */
async function saveTermin() {
const day = selectedDay()
if (!day || !state.slot || !customer) return
const btn = el('term-save')
const hint = el('term-save-hint')
btn.dataset.busy = '1'
btn.disabled = true
hint.dataset.sticky = '1'
hint.className = 'term-save-hint'
hint.textContent = 'Wird eingetragen…'
try {
const { appointment } = await api(`/api/portal-admin/customers/${encodeURIComponent(customer.id)}/appointment`, {
method: 'POST',
body: JSON.stringify({ date: day.key, time: state.slot, employeeEmail: PEOPLE[state.person].email }),
})
savedAppointment = appointment
hint.className = 'term-save-hint done'
const art = appointment.type && appointment.type.toLowerCase() !== 'akquise' ? `${appointment.type}-Ticket` : 'Ticket'
hint.textContent = `Eingetragen in ${art} ${appointment.woid ? `#${appointment.woid}` : ''} steht jetzt im Planboard.`
await loadAppointments()
} catch (err) {
hint.className = 'term-save-hint error'
hint.textContent = err.message
} finally {
delete btn.dataset.busy
render()
}
}
function openSummary() {
el('summary-text').value = summaryText()
el('summary-dialog').hidden = false
@@ -594,23 +717,30 @@ function bindEvents() {
break
case 'want':
state.wantTermin = target.dataset.want === '1'
clearSaveHint()
render()
break
case 'person':
state.person = target.dataset.id
state.day = ''
state.slot = ''
clearSaveHint()
render()
break
case 'day':
state.day = target.dataset.key
state.slot = ''
clearSaveHint()
render()
break
case 'slot':
state.slot = target.dataset.t
clearSaveHint()
render()
break
case 'save-termin':
saveTermin()
break
case 'open-summary': openSummary(); break
case 'close-summary': closeSummary(); break
case 'copy': copySummary(target); break
@@ -686,10 +816,12 @@ async function initKalkulation() {
}
restoreState()
await loadAppointments()
if (customer) {
el('hero-kicker').textContent = `Angebot für ${customer.name}`
if (!state.portalEmail) state.portalEmail = customer.email || ''
adoptExistingAppointment()
}
el('term-note').value = state.termNote