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

@@ -297,6 +297,16 @@ textarea.kal-input { min-height: 84px; }
background: var(--color-neutral-100); color: var(--color-neutral-700);
}
.term-status.fixed { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
.term-save { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 14px; }
.term-save button:disabled { opacity: 0.45; cursor: not-allowed; }
.term-save-hint { font-size: 13px; color: var(--color-neutral-600); flex: 1; min-width: 200px; text-wrap: pretty; }
.term-save-hint.done { color: var(--color-accent-2-700); font-weight: 700; }
.term-save-hint.error { color: var(--color-accent-800); font-weight: 700; }
.slot:disabled { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; }
.slot.taken-own {
border-color: var(--color-accent-2-500); background: var(--color-accent-2-100);
color: var(--color-accent-2-800); text-decoration: none; opacity: 1;
}
/* ------------------------------------------------------------ Kundenportal --- */
.portal-box {

View File

@@ -111,6 +111,13 @@
<div class="term-status" id="term-status">
<b>Noch kein Termin.</b> Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.
</div>
<div class="term-save">
<button type="button" class="btn-accent" id="term-save" data-act="save-termin" disabled>
Termin ins Ticket eintragen
</button>
<span class="term-save-hint" id="term-save-hint">Trägt den Termin am Akquise-Ticket ein — damit steht er im Planboard.</span>
</div>
</div>
<div style="margin-top:18px">

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

View File

@@ -13,6 +13,7 @@ import {
Query,
ID,
} from '../services/appwriteAdmin.js'
import { listAppointments, saveAppointment } from '../services/appointments.js'
import { getCustomerActivity } from '../services/customerActivity.js'
import { listInvoicesForClient } from '../services/invoiceNinja.js'
import { sanitizeMessage } from './chat.js'
@@ -185,6 +186,36 @@ router.get('/customers', async (req, res) => {
}
})
// ----------------------------------------------------------------- Termine --
// Termine haengen am Akquise-Ticket (workorders) und werden vom WOMS-Planboard
// gelesen. Siehe server/services/appointments.js.
router.get('/appointments', async (req, res) => {
try {
const appointments = await listAppointments({
from: String(req.query.from || ''),
to: String(req.query.to || ''),
})
return res.json({ appointments })
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Termine konnten nicht geladen werden' })
}
})
router.post('/customers/:customerId/appointment', async (req, res) => {
const { date, time, employeeEmail } = req.body || {}
try {
const appointment = await saveAppointment({
customerId: req.params.customerId,
employeeEmail,
date,
time,
})
return res.json({ success: true, appointment })
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'Termin konnte nicht gespeichert werden' })
}
})
// --------------------------------------------- Einzelner Kunde (Stammdaten) --
// Fuer die Rechnungsanalyse (kalkulation.html), die den Lead vorbefuellt.
router.get('/customers/:customerId', async (req, res) => {

View File

@@ -0,0 +1,159 @@
/* Termine der Rechnungsanalyse.
Ein Termin ist kein eigener Datensatz, sondern haengt am Akquise-Ticket des
Kunden (workorders): startDate + startTime + endTime + assignedTo. Das WOMS-
Planboard liest genau diese Felder, damit Termin und Ticket nie auseinander
laufen. Achtung: WOMS speichert Datum als Text "dd.mm.yyyy" und Uhrzeit als
"hhmm" - beides wird hier erzeugt und beim Lesen tolerant geparst. */
import { config } from '../config.js'
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
const TICKET_LIMIT = 500
/* ------------------------------------------------------------- Formate --- */
// "2026-08-12" -> "12.08.2026"
export function isoToGerman(iso) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso || ''))
if (!m) return ''
return `${m[3]}.${m[2]}.${m[1]}`
}
// "12.08.2026" und "12.8.2026" -> "2026-08-12"
export function germanToIso(value) {
const m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(String(value || '').trim())
if (!m) return ''
return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`
}
// "09:00" -> "0900"
export function timeToWoms(value) {
const m = /^(\d{1,2}):?(\d{2})$/.exec(String(value || '').trim())
if (!m) return ''
return `${m[1].padStart(2, '0')}${m[2]}`
}
// "0900" -> "09:00"
export function womsToTime(value) {
const raw = String(value || '').trim()
if (!raw) return ''
const m = /^(\d{1,2}):?(\d{2})$/.exec(raw)
if (!m) return ''
return `${m[1].padStart(2, '0')}:${m[2]}`
}
function addHour(time) {
const m = /^(\d{2}):(\d{2})$/.exec(womsToTime(time))
if (!m) return ''
const hour = (Number(m[1]) + 1) % 24
return `${String(hour).padStart(2, '0')}${m[2]}`
}
/* ---------------------------------------------------------- Mitarbeiter --- */
export async function findEmployeeByEmail(email) {
const wanted = String(email || '').trim().toLowerCase()
if (!wanted) return null
const employees = await listDocuments(config.collections.employees, [Query.limit(100)])
return employees.find((e) => String(e.email || '').toLowerCase() === wanted) || null
}
/* -------------------------------------------------------------- Tickets --- */
function isClosed(ticket) {
return String(ticket.status || '').toLowerCase() === 'closed'
}
/* Das Ticket, an das der Termin gehoert: bevorzugt das offene Akquise-Ticket.
Gibt es keins, kommt nur ein anderes offenes Ticket in Frage, das noch KEIN
Datum traegt - bei einem Projekt- oder Webpage-Ticket bedeutet startDate den
Projektstart, den wuerden wir sonst still ueberschreiben. */
export async function findAcquisitionTicket(customerId) {
const tickets = await listDocuments(config.collections.workorders, [
Query.equal('customerId', customerId),
Query.limit(100),
])
if (!tickets.length) return null
const newestFirst = [...tickets].sort((a, b) => String(b.$createdAt || '').localeCompare(String(a.$createdAt || '')))
const akquise = newestFirst.filter((t) => String(t.type || '').toLowerCase() === 'akquise')
if (akquise.length) return akquise.find((t) => !isClosed(t)) || akquise[0]
return newestFirst.find((t) => !isClosed(t) && !t.startDate) || null
}
function toAppointment(ticket, employeesByUserId) {
const dateIso = germanToIso(ticket.startDate)
const time = womsToTime(ticket.startTime)
if (!dateIso) return null
const employee = employeesByUserId[ticket.assignedTo] || null
return {
ticketId: ticket.$id,
woid: ticket.woid || '',
topic: ticket.topic || ticket.title || '',
type: ticket.type || '',
status: ticket.status || '',
customerId: ticket.customerId || '',
customerName: ticket.customerName || '',
customerLocation: ticket.customerLocation || '',
date: dateIso,
time,
endTime: womsToTime(ticket.endTime),
employeeUserId: ticket.assignedTo || '',
employeeName: employee?.displayName || ticket.assignedName || '',
employeeEmail: employee?.email || '',
}
}
/* Alle terminierten Tickets in einem Zeitraum (ISO-Datum, inklusive). */
export async function listAppointments({ from = '', to = '' } = {}) {
const [tickets, employees] = await Promise.all([
listDocuments(config.collections.workorders, [Query.limit(TICKET_LIMIT)]),
listDocuments(config.collections.employees, [Query.limit(100)]),
])
const employeesByUserId = {}
for (const e of employees) employeesByUserId[e.userId] = e
return tickets
.map((t) => toAppointment(t, employeesByUserId))
.filter((a) => a && (!from || a.date >= from) && (!to || a.date <= to))
.sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time))
}
/* Termin am Akquise-Ticket eintragen. */
export async function saveAppointment({ customerId, employeeEmail, date, time }) {
const germanDate = isoToGerman(date)
const womsTime = timeToWoms(time)
if (!germanDate) throw badRequest('Ungültiges Datum')
if (!womsTime) throw badRequest('Ungültige Uhrzeit')
const employee = await findEmployeeByEmail(employeeEmail)
if (!employee) throw badRequest(`Kein Mitarbeiter mit der E-Mail ${employeeEmail} in WOMS gefunden`)
const ticket = await findAcquisitionTicket(customerId)
if (!ticket) {
throw badRequest('Kein passendes Ticket gefunden. Bitte in WOMS ein Akquise-Ticket für diesen Kunden anlegen.')
}
const updated = await updateDocument(config.collections.workorders, ticket.$id, {
startDate: germanDate,
startTime: womsTime,
endTime: addHour(womsTime),
assignedTo: employee.userId,
assignedName: employee.displayName || '',
})
return {
ticketId: updated.$id,
woid: updated.woid || '',
topic: updated.topic || '',
type: updated.type || '',
date,
time: womsToTime(womsTime),
employeeName: employee.displayName || '',
}
}
function badRequest(message) {
const error = new Error(message)
error.status = 400
return error
}