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);
|
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-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 --- */
|
/* ------------------------------------------------------------ Kundenportal --- */
|
||||||
.portal-box {
|
.portal-box {
|
||||||
|
|||||||
@@ -111,6 +111,13 @@
|
|||||||
<div class="term-status" id="term-status">
|
<div class="term-status" id="term-status">
|
||||||
<b>Noch kein Termin.</b> Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.
|
<b>Noch kein Termin.</b> Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div style="margin-top:18px">
|
<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 = {
|
const PEOPLE = {
|
||||||
justin: {
|
justin: {
|
||||||
name: 'Justin', role: 'Website, Angebote, Betreuung', initial: 'J',
|
name: 'Justin', role: 'Website, Angebote, Betreuung', initial: 'J',
|
||||||
|
email: 'justin@webklar.com',
|
||||||
slots: {
|
slots: {
|
||||||
1: ['09:00', '10:00', '11:00', '16:00', '17:00'],
|
1: ['09:00', '10:00', '11:00', '16:00', '17:00'],
|
||||||
2: ['09:00', '10:00', '16:00', '17:00'],
|
2: ['09:00', '10:00', '16:00', '17:00'],
|
||||||
@@ -133,6 +134,7 @@ const PEOPLE = {
|
|||||||
},
|
},
|
||||||
kenso: {
|
kenso: {
|
||||||
name: 'Kenso', role: 'Shop, Software, Automatisierung', initial: 'K',
|
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'] },
|
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 customer = null
|
||||||
let storageKey = 'wk-kalkulation:neu'
|
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 --- */
|
/* --------------------------------------------------------------- Helfer --- */
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
return String(str ?? '')
|
return String(str ?? '')
|
||||||
@@ -179,10 +186,11 @@ function eur(n) {
|
|||||||
return Number(n || 0).toLocaleString('de-DE', { maximumFractionDigits: 0 }) + ' €'
|
return Number(n || 0).toLocaleString('de-DE', { maximumFractionDigits: 0 }) + ' €'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api(path) {
|
async function api(path, options = {}) {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
})
|
})
|
||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) throw new Error(data.error || `Fehler ${response.status}`)
|
if (!response.ok) throw new Error(data.error || `Fehler ${response.status}`)
|
||||||
@@ -285,14 +293,31 @@ function totals() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- Termin --- */
|
/* ---------------------------------------------------------------- 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) {
|
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) {
|
function dayKey(date) {
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
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() {
|
function terminDays() {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
@@ -300,13 +325,34 @@ function terminDays() {
|
|||||||
for (let i = 1; i <= 14; i += 1) {
|
for (let i = 1; i <= 14; i += 1) {
|
||||||
const d = new Date(today)
|
const d = new Date(today)
|
||||||
d.setDate(today.getDate() + i)
|
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
|
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() {
|
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) {
|
function longDay(date) {
|
||||||
@@ -360,6 +406,7 @@ function summaryText() {
|
|||||||
if (term) {
|
if (term) {
|
||||||
lines.push('', 'TERMIN', term)
|
lines.push('', 'TERMIN', term)
|
||||||
if (state.termNote) lines.push(`Thema: ${state.termNote}`)
|
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.notes) lines.push('', 'NOTIZEN', state.notes)
|
||||||
if (state.portalEmail || state.portalPass) {
|
if (state.portalEmail || state.portalPass) {
|
||||||
@@ -475,7 +522,7 @@ function renderTermin() {
|
|||||||
const days = terminDays()
|
const days = terminDays()
|
||||||
el('days-label').textContent = `Freie Tage — ${PEOPLE[state.person].name}`
|
el('days-label').textContent = `Freie Tage — ${PEOPLE[state.person].name}`
|
||||||
el('day-list').innerHTML = days.map((d) => {
|
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
|
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'}>
|
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>
|
<span class="wd">${d.date.toLocaleDateString('de-DE', { weekday: 'short' }).replace('.', '')}</span>
|
||||||
@@ -489,12 +536,35 @@ function renderTermin() {
|
|||||||
if (day) {
|
if (day) {
|
||||||
el('slots-label').textContent = `Uhrzeit am ${longDay(day.date)}`
|
el('slots-label').textContent = `Uhrzeit am ${longDay(day.date)}`
|
||||||
el('slot-list').innerHTML = day.slots.map((s) => {
|
el('slot-list').innerHTML = day.slots.map((s) => {
|
||||||
|
const taken = slotTaken(day.key, s)
|
||||||
const on = state.slot === 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('')
|
}).join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
renderTermStatus()
|
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() {
|
function renderTermStatus() {
|
||||||
@@ -538,6 +608,59 @@ function setQty(id, value) {
|
|||||||
render()
|
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() {
|
function openSummary() {
|
||||||
el('summary-text').value = summaryText()
|
el('summary-text').value = summaryText()
|
||||||
el('summary-dialog').hidden = false
|
el('summary-dialog').hidden = false
|
||||||
@@ -594,23 +717,30 @@ function bindEvents() {
|
|||||||
break
|
break
|
||||||
case 'want':
|
case 'want':
|
||||||
state.wantTermin = target.dataset.want === '1'
|
state.wantTermin = target.dataset.want === '1'
|
||||||
|
clearSaveHint()
|
||||||
render()
|
render()
|
||||||
break
|
break
|
||||||
case 'person':
|
case 'person':
|
||||||
state.person = target.dataset.id
|
state.person = target.dataset.id
|
||||||
state.day = ''
|
state.day = ''
|
||||||
state.slot = ''
|
state.slot = ''
|
||||||
|
clearSaveHint()
|
||||||
render()
|
render()
|
||||||
break
|
break
|
||||||
case 'day':
|
case 'day':
|
||||||
state.day = target.dataset.key
|
state.day = target.dataset.key
|
||||||
state.slot = ''
|
state.slot = ''
|
||||||
|
clearSaveHint()
|
||||||
render()
|
render()
|
||||||
break
|
break
|
||||||
case 'slot':
|
case 'slot':
|
||||||
state.slot = target.dataset.t
|
state.slot = target.dataset.t
|
||||||
|
clearSaveHint()
|
||||||
render()
|
render()
|
||||||
break
|
break
|
||||||
|
case 'save-termin':
|
||||||
|
saveTermin()
|
||||||
|
break
|
||||||
case 'open-summary': openSummary(); break
|
case 'open-summary': openSummary(); break
|
||||||
case 'close-summary': closeSummary(); break
|
case 'close-summary': closeSummary(); break
|
||||||
case 'copy': copySummary(target); break
|
case 'copy': copySummary(target); break
|
||||||
@@ -686,10 +816,12 @@ async function initKalkulation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
restoreState()
|
restoreState()
|
||||||
|
await loadAppointments()
|
||||||
|
|
||||||
if (customer) {
|
if (customer) {
|
||||||
el('hero-kicker').textContent = `Angebot für ${customer.name}`
|
el('hero-kicker').textContent = `Angebot für ${customer.name}`
|
||||||
if (!state.portalEmail) state.portalEmail = customer.email || ''
|
if (!state.portalEmail) state.portalEmail = customer.email || ''
|
||||||
|
adoptExistingAppointment()
|
||||||
}
|
}
|
||||||
|
|
||||||
el('term-note').value = state.termNote
|
el('term-note').value = state.termNote
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
ID,
|
ID,
|
||||||
} from '../services/appwriteAdmin.js'
|
} from '../services/appwriteAdmin.js'
|
||||||
|
import { listAppointments, saveAppointment } from '../services/appointments.js'
|
||||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||||
import { sanitizeMessage } from './chat.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) --
|
// --------------------------------------------- Einzelner Kunde (Stammdaten) --
|
||||||
// Fuer die Rechnungsanalyse (kalkulation.html), die den Lead vorbefuellt.
|
// Fuer die Rechnungsanalyse (kalkulation.html), die den Lead vorbefuellt.
|
||||||
router.get('/customers/:customerId', async (req, res) => {
|
router.get('/customers/:customerId', async (req, res) => {
|
||||||
|
|||||||
159
server/services/appointments.js
Normal file
159
server/services/appointments.js
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user