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:
@@ -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) => {
|
||||
|
||||
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