From ac264dc9bad00d2fd158cfc61dbe131afab89989 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 22:09:51 +0000 Subject: [PATCH] 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 --- public/kalkulation.css | 10 ++ public/kalkulation.html | 7 ++ public/kalkulation.js | 144 +++++++++++++++++++++++++++-- server/routes/portalAdmin.js | 31 +++++++ server/services/appointments.js | 159 ++++++++++++++++++++++++++++++++ 5 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 server/services/appointments.js diff --git a/public/kalkulation.css b/public/kalkulation.css index 79daa98..8a3f25f 100644 --- a/public/kalkulation.css +++ b/public/kalkulation.css @@ -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 { diff --git a/public/kalkulation.html b/public/kalkulation.html index 35e4f17..8c1bbc3 100644 --- a/public/kalkulation.html +++ b/public/kalkulation.html @@ -111,6 +111,13 @@
Noch kein Termin. Wähle einen Tag und eine Uhrzeit — beides landet in der Zusammenfassung.
+ +
+ + Trägt den Termin am Akquise-Ticket ein — damit steht er im Planboard. +
diff --git a/public/kalkulation.js b/public/kalkulation.js index ede86dd..c0c99d1 100644 --- a/public/kalkulation.js +++ b/public/kalkulation.js @@ -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 `` + 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 `` }).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 diff --git a/server/routes/portalAdmin.js b/server/routes/portalAdmin.js index a5aee9b..9f23c3b 100644 --- a/server/routes/portalAdmin.js +++ b/server/routes/portalAdmin.js @@ -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) => { diff --git a/server/services/appointments.js b/server/services/appointments.js new file mode 100644 index 0000000..7068e62 --- /dev/null +++ b/server/services/appointments.js @@ -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 +}