Portal: Admin-Panel, Rechnungsanalyse und alte Admin-Schnittstellen entfernt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-02 03:50:14 +00:00
1 parent 3a4fa581c0
commit 7c00633db0
43 files changed
+229 -4001

No files matched your search

-159
View File
@@ -1,159 +0,0 @@
/* 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
}
+2 -1
View File
@@ -11,7 +11,8 @@ import {
/**
* Kundenansicht aus dem Ticketsystem (Spec 2026-10-01, Abschnitt 3): ein Mitarbeiter oeffnet per Einmal-Link das
* Portal eines Kunden. Die Sitzung traegt eine zufaellige Kennung (viewId) gegen Verwechslung zweier Tabs und
* endet nach 2 Stunden. Sitzungen ohne viewId stammen aus "Als Kunde ansehen" im alten Admin-Panel.
* endet nach 2 Stunden. Sitzungen ohne viewId ("Als Kunde ansehen" im frueheren Admin-Panel) gelten als beendet
* (kundenansichtGuard, /api/auth/me).
*/
export const KUNDENANSICHT_TTL_MS = 2 * 60 * 60 * 1000
const COOKIE_GRACE_MS = 60 * 60 * 1000
+1 -1
View File
@@ -83,7 +83,7 @@ const lastCustomerNoticeByCustomer = new Map() // customerId -> timestamp
/**
* Benachrichtigt den Kunden per E-Mail ueber eine neue Chatnachricht vom Team (K17), ohne BCC.
* Wirft nie; das Ergebnis zeigt das Admin-Panel unter der Nachricht an.
* Wirft nie; das Ergebnis zeigt das Ticketsystem unter der Nachricht an.
* @returns {Promise<{ sent: boolean, reason?: string, to?: string }>}
*/
export async function sendCustomerChatNotice(customer, { sender, text }) {
+2 -2
View File
@@ -11,7 +11,7 @@ import { shortRef } from './takeoverCredits.js'
* Individuelle Abo-Angebote.
*
* Der Admin vereinbart mit einem Kunden einen Sonderpreis fuer die ersten Monate (z. B. 2 Monate
* je 25 € statt 50 €) und legt ihn vorab im Admin-Panel an. Jedes Angebot bekommt sofort einen
* je 25 € statt 50 €) und legt ihn vorab im Ticketsystem an. Jedes Angebot bekommt sofort einen
* eigenen, einmal einloesbaren Stripe-Coupon (amount_off, duration "repeating" ueber die
* Angebotsmonate); danach gilt automatisch wieder der regulaere Preis.
* - Kunde ohne Abo: Portal zeigt das Angebot, die Bestelluebersicht nennt den Angebotspreis und
@@ -24,7 +24,7 @@ const COLLECTION = () => config.collections.aboOffers
export const OFFER_STATUS = { open: 'offen', active: 'aktiv', withdrawn: 'zurueckgezogen' }
// Anzeige im Team-Hinweis (wie im Admin-Bereich)
// Anzeige im Team-Hinweis (wie im Ticketsystem)
const OFFER_STATUS_LABEL = { offen: 'offen', aktiv: 'aktiv (schon eingelöst)', zurueckgezogen: 'zurückgezogen' }
function httpError(status, message) {
+2 -2
View File
@@ -45,8 +45,8 @@ async function currentPasswordVersion(userId) {
}
/**
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Admin-Sessions
* ("Als Kunde ansehen") und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
* false, wenn die Kunden-Session von vor der letzten Passwortaenderung stammt. Sessions der
* Kundenansicht (Mitarbeiter) und Sessions ohne pwv werden nicht geprueft. Ist Appwrite nicht
* erreichbar, gilt die Session weiter (sonst waeren bei jeder Stoerung alle abgemeldet).
*/
export async function isSessionPasswordCurrent(session) {
+70
View File
@@ -0,0 +1,70 @@
import { config } from '../config.js'
import { listDocuments, Query } from './appwriteAdmin.js'
import { isPreviewProjectReady } from './previewReadiness.js'
import { createStaffPreviewToken } from './staffPreviewTokens.js'
function safePreviewUrl(url) {
try {
const parsed = new URL(String(url || ''))
if (
parsed.hostname.endsWith('.project.webklar.com') ||
parsed.hostname === 'project.webklar.com'
) {
return parsed.toString()
}
} catch {
/* ignore */
}
return null
}
function previewUrlForSubdomain(subdomain) {
const safe = String(subdomain || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '')
if (!safe) return null
return `https://${safe}.${config.preview.baseHost}`
}
/**
* Einmal-Link fuer die Preview eines Projekts (eingeloest von GET /api/auth/staff-preview), genutzt vom
* Team-Eingang (/api/team/preview-link, alle Mitarbeiter). Liefert { status, body }.
*/
export async function buildStaffPreviewLink({ userId, email, name, subdomain: rawSubdomain, previewUrl: rawPreviewUrl }) {
const subdomain = String(rawSubdomain || '')
.trim()
.toLowerCase()
const previewUrl = safePreviewUrl(rawPreviewUrl) || previewUrlForSubdomain(subdomain)
if (!subdomain || !previewUrl) {
return { status: 400, body: { error: 'subdomain oder gueltige previewUrl erforderlich' } }
}
try {
const projects = await listDocuments(config.collections.websiteProjects, [
Query.equal('subdomain', subdomain),
Query.limit(1),
])
const project = projects[0]
if (!project) {
return { status: 404, body: { error: 'Projekt nicht gefunden' } }
}
if (!isPreviewProjectReady(project)) {
return { status: 409, body: { error: 'Projekt-Vorschau ist noch nicht bereit' } }
}
const token = createStaffPreviewToken({
userId,
email: email || '',
name: name || email || 'Admin',
previewUrl,
customerId: project.customerId || '',
})
const portalBase = `https://${config.preview.baseHost}`
return { status: 200, body: { openUrl: `${portalBase}/api/auth/staff-preview?token=${encodeURIComponent(token)}` } }
} catch (err) {
return { status: err.status || 500, body: { error: err.message || 'Preview-Login fehlgeschlagen' } }
}
}
+2 -3
View File
@@ -21,9 +21,8 @@ export function createStaffPreviewToken(payload) {
}
/**
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) vom Wechsel ins Projekt-Admin
* ('switch') und von der Kundenansicht aus dem Ticketsystem ('kundenansicht'); ein Token fuer den falschen
* Zweck ist trotzdem verbraucht.
* Einmal einloesen. purpose trennt Preview-Links ('preview', Standard) von der Kundenansicht aus dem
* Ticketsystem ('kundenansicht'); ein Token fuer den falschen Zweck ist trotzdem verbraucht.
*/
export function consumeStaffPreviewToken(token, purpose = 'preview') {
purgeExpired()