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:
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user