Merge remote-tracking branch 'origin/test' into feature/oeffnungszeiten

Zusammenführung von TP2 und Webshop im Ticketsystem (origin/test 00fa229) mit Öffnungszeiten live. Die Funktionen
stehen in der Reihenfolge news, termine, shop, zeiten.

Konfliktdateien (6, erwartet war nur CustomerFunktionen.jsx; origin/test enthält seit der Vorbereitung auch die
beiden Webshop-Commits 0a2eab8 und 00fa229): scripts/setup-legal.mjs, src/components/customer/CustomerFunktionen.jsx,
src/lib/funktionenView.js, src/lib/teamApi.js, tests/setup-legal.test.mjs, tests/unit/funktionenView.test.mjs.

Entscheidungen: ersatzTicket-Behandlung der Zeiten und speicherStatus/K16_WARNUNG von TP2 bleiben beide in save()
(Abschalten der Zeiten zeigt abschaltMeldung, jede andere Aktion speicherStatus); Rechtliches-Karten Webshop (sort 10, 11)
vor der Karte der Öffnungszeiten (sort 12, DOCUMENTS hat 12 Einträge); siteKeyOf kennt termine, shop und zeiten; aktionen
behandelt shop und zeiten wie den Terminplaner (ticketbasiert, Abnahme der Zeiten mit hindernis); teamApi trägt Webshop-
und Zeiten-Aufrufe nebeneinander.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:19:42 +00:00
commit 8319084930
19 files changed
+906 -165

No files matched your search

+140 -16
View File
@@ -1,4 +1,4 @@
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner, Öffnungszeiten live), Aktionen je Zustand und
// Kunden-Reiter "Funktionen": Register der Funktionen je Projekt (News, Terminplaner, Webshop, Öffnungszeiten live), Aktionen je Zustand und
// Einarbeiten der Portal-Antwort. Rein, unter Node testbar.
import { fmtYmd, fmtBerlin } from './format'
@@ -24,6 +24,16 @@ export const FUNKTIONEN = [
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
{
key: 'shop',
label: 'Webshop',
bestaetigen: {
activate: 'Webshop ohne Zahlung aktivieren?',
disable: 'Webshop abschalten? Auf der Website sind dann keine Bestellungen mehr möglich.',
abnahme: 'Webshop freischalten? Der Kunde bekommt die Mail „bereit“ und der Shop kann geöffnet werden.',
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
},
},
{
key: 'zeiten',
label: 'Öffnungszeiten live',
@@ -68,12 +78,12 @@ export function abgeschaltetText(feature) {
export const funktionOf = (project, key) => project?.features?.[key] || { state: 'off' }
/**
* Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner und bei Oeffnungszeiten live
* Site-Key (Schluessel fuer den Einbau auf der Kundenwebsite) einer Funktion: nur beim Terminplaner, beim Webshop und bei Oeffnungszeiten live
* und nur, wenn das Portal einen nennt (feature.siteKey); sonst ''.
*/
export function siteKeyOf(feature, key) {
const wert = feature?.siteKey
return (key === 'termine' || key === 'zeiten') && typeof wert === 'string' ? wert.trim() : ''
return (key === 'termine' || key === 'shop' || key === 'zeiten') && typeof wert === 'string' ? wert.trim() : ''
}
/** Beschriftung der Aktionsknoepfe (Aktion = action im POST /funktionen/:customerId/:key). */
@@ -86,7 +96,7 @@ export const AKTION_LABEL = {
/**
* Moegliche Aktionen je Zustand. News (und ohne key): Aktivieren (ohne Zahlung) ausser bei "active", Abschalten solange bestellt, in
* Einrichtung oder aktiv. Terminplaner und Oeffnungszeiten live (beide mit Ticket-Freischaltung): "ordered" nur Abschalten (Aktivieren
* Einrichtung oder aktiv. Terminplaner, Webshop und Oeffnungszeiten live (alle mit Ticket-Freischaltung): "ordered" nur Abschalten (Aktivieren
* ohne Zahlung wuerde eine spaetere Zahlung im Portal verschlucken), "setup" Freischalten nach der Abnahme (mit ticketMissing zuerst
* "Ticket nachlegen"), "active" nur Abschalten; sonst ("off", "disabled", ohne Zustand) Aktivieren. Fuer "disabled" gibt es kein
* Abschalten, auch wenn das Ersatz-Ticket beim Abschalten fehlschlug (das Portal merkt sich das nicht; die Antwort nennt es nur einmal).
@@ -94,7 +104,7 @@ export const AKTION_LABEL = {
* (hindernisText); bei Oeffnungszeiten live entfaellt sie, solange der Kunde noch keine Zeiten eingetragen hat ('zeiten_fehlen').
*/
export function aktionen(state, key, { ticketMissing = false, hindernis = null } = {}) {
if (key === 'termine' || key === 'zeiten') {
if (key === 'termine' || key === 'shop' || key === 'zeiten') {
if (state === 'ordered' || state === 'active') return ['disable']
if (state === 'setup') {
const abnahme = key === 'zeiten' && hindernis ? [] : ['abnahme']
@@ -181,10 +191,16 @@ export function mergeFunktion(projects, projectId, key, aktualisiert) {
return projects.map((p) => (p.projectId === projectId ? { ...p, features: { ...p.features, [key]: view } } : p))
}
// --- Aktivieren (ohne Zahlung) mit Zustimmung in Textform (Modus on des Terminplaners; Portal: GET /funktionen/:customerId => termine) ---
// --- Aktivieren (ohne Zahlung) mit Zustimmung in Textform (Modus on des Terminplaners und des Webshops; Portal: GET /funktionen/:customerId => termine, shop) ---
/** true, wenn diese Aktion die Zustimmung in Textform braucht: nur "Aktivieren (ohne Zahlung)" des Terminplaners und nur, wenn das Portal es verlangt (termine.textformNoetig === true). */
export const textformNoetig = (key, action, termine) => key === 'termine' && action === 'activate' && termine?.textformNoetig === true
/** Funktionen, deren Aktivierung durch das Team die Zustimmung des Kunden in Textform brauchen kann (Portal: TEXTFORM_KEYS). */
export const TEXTFORM_FUNKTIONEN = ['termine', 'shop']
/**
* true, wenn diese Aktion die Zustimmung in Textform braucht: nur "Aktivieren (ohne Zahlung)" des Terminplaners oder des Webshops und nur, wenn das
* Portal es verlangt (feld = Portal-Feld dieser Funktion, termine bzw. shop, feld.textformNoetig === true).
*/
export const textformNoetig = (key, action, feld) => TEXTFORM_FUNKTIONEN.includes(key) && action === 'activate' && feld?.textformNoetig === true
/** Kaestchentext (wie im Portal-Nachweis, ohne Datum und Weg); die beiden Links setzt die Komponente. */
export const termineTextformLabel = (version) => ({
@@ -195,10 +211,23 @@ export const termineTextformLabel = (version) => ({
after: ` (Fassung ${version}) in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.`,
})
/** Kaestchentext des Webshops; die Erklaerungen entsprechen den Bestelltexten "unterlagen", "verantwortung" und "stripe", die das Portal im Nachweis speichert. */
export const shopTextformLabel = (version) => ({
before: 'Der Kunde hat der ',
lb: 'Leistungsbeschreibung Webshop',
between: ' und dem ',
avv: 'Auftragsverarbeitungsvertrag Webshop',
after: ` (Fassung ${version}) in Textform zugestimmt und bestätigt, dass er als Unternehmer in eigenem Namen verkauft, für seine Produkte, Preise, Produktangaben und Rechtstexte selbst verantwortlich ist, keine der in der Leistungsbeschreibung ausgeschlossenen Waren verkauft und die Zahlungen seiner Käufer über sein eigenes Stripe-Konto laufen.`,
})
/** Textvorlage fuer den Chat (Kopierknopf); links = die versionierten Adressen aus dem Portal (termine.links). */
export const termineChatTemplate = (version, links) =>
`Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${version}: ${links.lb} · ${links.avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.`
/** Textvorlage fuer den Chat beim Webshop; links = shop.links. Die Bestaetigung deckt dieselben Erklaerungen wie shopTextformLabel. */
export const shopChatTemplate = (version, links) =>
`Damit wir den Webshop für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Webshop und der Auftragsverarbeitungsvertrag Webshop in der Fassung ${version}: ${links.lb} · ${links.avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Du als Unternehmer in eigenem Namen verkaufst, für Deine Produkte, Preise, Produktangaben und Rechtstexte selbst verantwortlich bist, keine der in der Leistungsbeschreibung ausgeschlossenen Waren verkaufst und Deine Zahlungen über Dein eigenes Stripe-Konto laufen.`
/** Nur geprüfte https-Adressen werden Links; alles andere ''. */
const httpsUrl = (wert) => {
if (typeof wert !== 'string') return ''
@@ -210,23 +239,25 @@ const httpsUrl = (wert) => {
}
/**
* Alles, was der Bestaetigungsdialog zeigt, aus dem Portal-Feld termine: { version, links { lb, avv } (nur https, sonst ''), label, vorlage }.
* Alles, was der Bestaetigungsdialog zeigt, aus dem Portal-Feld der Funktion (feld = termine bzw. shop, key = 'termine' | 'shop'):
* { version, links { lb, avv } (nur https, sonst ''), label, vorlage }.
* vorlage nur, wenn Fassung und beide Adressen da sind (eine Vorlage mit leeren Adressen geht nie an den Kunden), sonst ''.
*/
export function textformAnsicht(termine) {
const version = typeof termine?.avvVersion === 'string' ? termine.avvVersion : ''
const links = { lb: httpsUrl(termine?.links?.lb), avv: httpsUrl(termine?.links?.avv) }
export function textformAnsicht(feld, key = 'termine') {
const shop = key === 'shop'
const version = typeof feld?.avvVersion === 'string' ? feld.avvVersion : ''
const links = { lb: httpsUrl(feld?.links?.lb), avv: httpsUrl(feld?.links?.avv) }
return {
version,
links,
label: termineTextformLabel(version),
vorlage: version && links.lb && links.avv ? termineChatTemplate(version, links) : '',
label: (shop ? shopTextformLabel : termineTextformLabel)(version),
vorlage: version && links.lb && links.avv ? (shop ? shopChatTemplate : termineChatTemplate)(version, links) : '',
}
}
/** Alle drei Pflichtangaben (Haken, Datum zwischen Fassung und heute, Weg); ohne Fassung im Portal-Feld nie bereit. */
export function textformBereit({ confirmed, date, channel, today }, termine) {
const minDate = typeof termine?.avvVersion === 'string' ? termine.avvVersion : ''
export function textformBereit({ confirmed, date, channel, today }, feld) {
const minDate = typeof feld?.avvVersion === 'string' ? feld.avvVersion : ''
if (!minDate) return false
return canSubmitTextform({ confirmed, date, channel, today, minDate })
}
@@ -240,3 +271,96 @@ export function featureBody({ projectId, action, note, textformDate, textformCha
}
return body
}
/** K16 (Unterlagen an den Kunden) ging beim Aktivieren nicht raus: Antwort mit warnung: true (Portal, funktionenAdmin). */
export const K16_WARNUNG = 'Eingeschaltet, aber die E-Mail mit den Unterlagen (K16) ging nicht raus. Bitte die Leistungsbeschreibung und den AVV von Hand an den Kunden senden.'
/** Statuszeile nach dem Speichern einer Aktion: Warnung statt "Gespeichert.", wenn die Antwort warnung: true traegt. */
export const speicherStatus = (antwort) => (antwort?.warnung === true ? { ok: false, text: K16_WARNUNG } : { ok: true, text: 'Gespeichert.' })
// --- Webshop: Anfrage des Kunden und Stand des Shops (Portal: GET /shop/:customerId) ---
const WARENART_LABEL = { waren: 'Waren', lebensmittel: 'Lebensmittel', kleidung: 'Kleidung', kunst: 'Kunst/Handgemachtes', sonstiges: 'Sonstiges' }
const ARTIKELZAHL_LABEL = { bis20: 'bis 20', bis100: 'bis 100', mehr: 'mehr als 100' }
const has = (map, key) => Object.prototype.hasOwnProperty.call(map, key)
/** Warenart-Code -> Beschriftung; unbekannt oder leer '–'. */
export const warenartLabel = (code) => (has(WARENART_LABEL, code) ? WARENART_LABEL[code] : code ? String(code) : '–')
/** Artikelzahl-Code -> Beschriftung; unbekannt oder leer '–'. */
export const artikelzahlLabel = (code) => (has(ARTIKELZAHL_LABEL, code) ? ARTIKELZAHL_LABEL[code] : code ? String(code) : '–')
/** Status der Anfrage -> [Ton des Abzeichens, Beschriftung]. */
export const ANFRAGE_STATUS = {
offen: ['warn', 'Offen'],
angenommen: ['ok', 'Angenommen'],
abgelehnt: ['danger', 'Abgelehnt'],
zurueckgezogen: ['muted', 'Zurückgezogen'],
}
export const anfrageStatus = (status) => (has(ANFRAGE_STATUS, status) ? ANFRAGE_STATUS[status] : ['muted', status ? String(status) : '–'])
const jaNein = (wert) => (wert === true ? 'Ja' : wert === false ? 'Nein' : '–')
const textOder = (wert) => (typeof wert === 'string' && wert.trim() ? wert.trim() : '–')
/** Zeilen der Anfrage fuer die Anzeige: [{ label, text }]. Ohne Anfrage []. */
export function anfrageZeilen(a) {
if (!a || typeof a !== 'object') return []
const wege = [a.versand === true ? 'Versand' : '', a.abholung === true ? 'Abholung' : ''].filter(Boolean)
return [
{ label: 'Warenart', text: warenartLabel(a.warenart) },
{ label: 'Beschreibung', text: textOder(a.beschreibung) },
{ label: 'Artikelzahl', text: artikelzahlLabel(a.artikelzahl) },
{ label: 'Übergabe', text: wege.length ? wege.join(' und ') : '–' },
{ label: 'Unternehmer', text: jaNein(a.unternehmer) },
{ label: 'Keine ausgeschlossenen Waren', text: jaNein(a.keineAusgeschlossenen) },
{ label: 'Nachricht', text: textOder(a.nachricht) },
]
}
/** Kurzfassung der Anfrage in einer Zeile ("Waren, bis 20 Artikel, Versand und Abholung"); ohne Anfrage ''. */
export function anfrageText(a) {
if (!a || typeof a !== 'object') return ''
const wege = [a.versand === true ? 'Versand' : '', a.abholung === true ? 'Abholung' : ''].filter(Boolean).join(' und ')
const zahl = a.artikelzahl ? `${artikelzahlLabel(a.artikelzahl)} Artikel` : ''
return [warenartLabel(a.warenart), zahl, wege].filter((t) => t && t !== '–').join(', ')
}
/**
* Zahlungskonto des Kunden bei Stripe in Worten. status = stripe.status aus dem Portal (null, solange nichts geprueft wurde),
* hatKonto = stripe.accountId. "nicht verbunden" | "Zahlungen aktiv" | "Angaben fehlen: …".
*/
export function stripeText(status, hatKonto = false) {
if (!status || typeof status !== 'object') return hatKonto ? 'Konto angelegt, Stand noch nicht geprüft' : 'nicht verbunden'
if (status.charges_enabled === true && status.payouts_enabled === true) return 'Zahlungen aktiv'
if (status.charges_enabled === true) return 'Zahlungen aktiv, Auszahlungen noch nicht freigegeben'
const offen = [...new Set([...(Array.isArray(status.past_due) ? status.past_due : []), ...(Array.isArray(status.currently_due) ? status.currently_due : [])])]
if (offen.length) {
const gezeigt = offen.slice(0, 4).join(', ')
return `Angaben fehlen: ${gezeigt}${offen.length > 4 ? ` und ${offen.length - 4} weitere` : ''}`
}
if (status.disabled_reason) return `Angaben fehlen: ${status.disabled_reason}`
return status.details_submitted === true ? 'Angaben fehlen: Prüfung durch Stripe läuft' : 'Angaben fehlen: Einrichtung nicht abgeschlossen'
}
/** Ton fuer das Stripe-Abzeichen: ok (aktiv) | muted (nicht verbunden) | warn (Angaben fehlen). */
export const stripeTon = (status, hatKonto = false) => {
if (status?.charges_enabled === true) return 'ok'
return status || hatKonto ? 'warn' : 'muted'
}
/** Gibt es zu diesem Projekt etwas anzuzeigen (Anfrage vorhanden oder Webshop nicht "off")? */
export const shopHatEintrag = (shop) => !!shop && (shop.anfrage != null || (shop.state && shop.state !== 'off'))
/** Begruendung der Ablehnung: Pflicht, hoechstens 1000 Zeichen (ohne Rand). */
export const BEGRUENDUNG_MAX = 1000
export const begruendungOk = (text) => typeof text === 'string' && text.trim().length > 0 && text.trim().length <= BEGRUENDUNG_MAX
/** Rumpf fuer POST /shop/:customerId/anfrage. Begruendung nur beim Ablehnen. */
export function entscheidungBody({ projectId, entscheidung, begruendung }) {
const body = { projectId, entscheidung }
if (entscheidung === 'ablehnen') body.begruendung = String(begruendung ?? '').trim()
return body
}
/** Statuszeile nach der Entscheidung (Antwort { ok, mailFehler? }). */
export const entscheidungStatus = (antwort) =>
antwort?.mailFehler ? { ok: false, text: 'Entscheidung gespeichert, Mail an den Kunden fehlgeschlagen.' } : { ok: true, text: 'Gespeichert.' }
+6
View File
@@ -1,4 +1,5 @@
import { account } from './appwrite'
import { throttleRuns } from './throttleRuns'
const PROJECT_ADMIN_URL =
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
@@ -33,6 +34,11 @@ export async function importNewRepos() {
return adminFetch('/api/admin/gitea/import-repos', { method: 'POST' })
}
// Jedes Aufklappen eines Tickets fragte frueher alle Gitea-Repos ab (serverseitig unter einer
// globalen Sperre). Neue Repos kommen ohnehin per Webhook und 10-Minuten-Lauf - hier hoechstens
// ein Abgleich pro Minute; ein laufender Abgleich wird geteilt. null = uebersprungen.
export const importNewReposThrottled = throttleRuns(() => importNewRepos(), 60000)
export async function assignWebsiteProject(id, { customerId, ticketId }) {
return adminFetch(`/api/admin/website-projects/${id}/assign`, {
method: 'POST',
+27 -1
View File
@@ -6,8 +6,34 @@ export function isOutsideOrg(repoFullName) {
return org.toLowerCase() !== GITEA_ORG.toLowerCase()
}
// "Preview einschalten" nur, solange das Projekt noch keine Website hat. Mit Subdomain laeuft die
// Preview schon oder wird gerade gebaut (der Runner setzt 'deployed' erst danach) - Neubauen
// geht dann ueber den Preview-Block. Archivierte Projekte erst reaktivieren.
export function needsPreviewButton(project) {
return !project.previewUrl || project.status !== 'deployed'
if (project.archived) return false
return !project.subdomain || !project.previewUrl
}
// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht archiviert,
// keine Vorlage und noch nicht an diesem Ticket sind. Massgeblich ist das Flag `archived`
// (nicht status): nach dem Reaktivieren blieb status frueher auf 'archived' stehen.
export function assignableProjects(all, ticket, assignedIds) {
return (all || []).filter((p) =>
p.repoFullName &&
!assignedIds.has(p.$id) &&
!p.archived &&
!p.isTemplate &&
(!p.customerId || p.customerId === ticket.customerId)
)
}
// Kundenwechsel in der Projektliste: das bisherige Ticket nur behalten, wenn es dem neuen Kunden
// gehoert - sonst haengt das Projekt von Kunde B am Ticket von Kunde A.
export function ticketIdForCustomer(project, customerId, workorderById = {}) {
const ticketId = project?.ticketId || ''
if (!ticketId) return ''
if (customerId === project.customerId) return ticketId
return workorderById[ticketId]?.customerId === customerId ? ticketId : ''
}
export function assignSummary(projectName, result) {
+72
View File
@@ -0,0 +1,72 @@
// Ansichten der Projektliste (Projekte-Seite). Jedes Gitea-Repo ist ein Projekt; "Aktive Projekte"
// blendet Archiv und Vorlagen aus, "Alle inkl. Archiv" zeigt wirklich alles (Vorlagen nur Admins).
const active = (p) => !p.archived && !p.isTemplate
export function projectCounts(projects, { isAdmin = false } = {}) {
const list = projects || []
const act = list.filter(active)
return {
all: act.length,
unassigned: act.filter((p) => !p.customerId).length,
assigned: act.filter((p) => Boolean(p.customerId)).length,
archived: list.filter((p) => p.archived && (isAdmin || !p.isTemplate)).length,
templates: list.filter((p) => p.isTemplate && !p.archived).length,
everything: list.filter((p) => isAdmin || !p.isTemplate).length,
}
}
export function projectsInView(projects, filter, { isAdmin = false } = {}) {
const list = projects || []
if (filter === 'templates') return list.filter((p) => p.isTemplate && !p.archived)
if (filter === 'archived') return list.filter((p) => p.archived && (isAdmin || !p.isTemplate))
if (filter === 'everything') return list.filter((p) => isAdmin || !p.isTemplate)
const act = list.filter(active)
if (filter === 'unassigned') return act.filter((p) => !p.customerId)
if (filter === 'assigned') return act.filter((p) => Boolean(p.customerId))
return act
}
export const VIEW_LABELS = { all: 'Aktive Projekte', templates: 'Vorlagen', archived: 'Archiviert', everything: 'Alle inkl. Archiv' }
// Ansichten, die ein Projekt zeigen koennen - die Vorlagen-Ansicht gibt es nur fuer Admins
const viewsFor = (isAdmin) => ['all', ...(isAdmin ? ['templates'] : []), 'archived', 'everything']
/**
* Ansicht, in der ein Projekt (z. B. nach dem Bearbeiten) zu sehen ist: die aktuelle, wenn es dort
* steht, sonst die erste passende. Ohne passende Ansicht bleibt es bei der aktuellen.
* Hintergrund: SHK-vorlage zur Vorlage gemacht -> verschwand kommentarlos aus "Aktive Projekte".
*/
export function viewShowing(project, filter, { isAdmin = false } = {}) {
if (!project) return filter
for (const view of [filter, ...viewsFor(isAdmin)]) {
if (view === 'templates' && !isAdmin) continue
if (projectsInView([project], view, { isAdmin }).length) return view
}
return filter
}
/** Textsuche ueber Titel, Subdomain und Repo (leer passt immer). */
export function matchesSearch(project, search) {
const q = String(search || '').trim().toLowerCase()
if (!q) return true
return [project.projectName, project.subdomain, project.repoFullName, project.giteaRepoName].some((v) =>
String(v || '').toLowerCase().includes(q)
)
}
/**
* Treffer, die die aktuelle Ansicht ausblendet (Vorlagen nur fuer Admins, Archiv), je Ansicht gezaehlt.
* `matches` ist der Filter der Seite (Suche, Kunde, Mitarbeiter).
*/
export function hiddenMatches(projects, filter, matches, { isAdmin = false } = {}) {
if (filter === 'everything') return []
const shown = new Set(projectsInView(projects, filter, { isAdmin }).map((p) => p.$id))
const out = []
for (const view of [...(isAdmin ? ['templates'] : []), 'archived']) {
if (view === filter) continue
const count = projectsInView(projects, view, { isAdmin }).filter((p) => !shown.has(p.$id) && matches(p)).length
if (count) out.push({ view, count })
}
return out
}
+15 -12
View File
@@ -5,18 +5,21 @@ const UMLAUT_MAP = {
ß: 'ss',
}
// Subdomain/Repo-Kuerzel wie im Portal (slug.js): Umlaute ausgeschrieben statt "m-ller",
// hoechstens 63 Zeichen (Laenge eines DNS-Labels), ohne Bindestrich am Rand.
export function slugify(value) {
let v = String(value ?? '').trim().toLowerCase()
if (!v) return ''
v = v.replace(/[äöüß]/g, (ch) => UMLAUT_MAP[ch])
v = v.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
v = v.replace(/['’‘`]/g, '')
v = v.replace(/[^a-z0-9]+/g, '-')
v = v.replace(/^-+|-+$/g, '')
v = v.slice(0, 63)
return v.replace(/-+$/g, '')
}
export function suggestSubdomain(customerName, projectName) {
const base = (customerName || '').trim() || (projectName || '').trim()
if (!base) return ''
let value = base.toLowerCase()
value = value.replace(/[äöüß]/g, (ch) => UMLAUT_MAP[ch])
value = value.normalize('NFD').replace(/[̀-ͯ]/g, '')
value = value.replace(/['’‘`]/g, '')
value = value.replace(/[^a-z0-9]+/g, '-')
value = value.replace(/^-+|-+$/g, '')
value = value.slice(0, 63)
value = value.replace(/-+$/g, '')
return value
return slugify(base)
}
+11 -3
View File
@@ -1,7 +1,7 @@
import { account } from './appwrite'
import { createFinanceRequest } from './financeRequest'
import { createJwtCache } from './serverpulsAuth'
import { anleitungPfad, featureBody } from './funktionenView'
import { anleitungPfad, featureBody, entscheidungBody } from './funktionenView'
import { ersatzPfad, zeitenTeamPfad } from './zeitenView'
/**
@@ -95,8 +95,8 @@ export const markStorageDone = (id) => request(`/storage-tasks/${enc(id)}/done`,
export const getFunktionen = (customerId) => request(`/funktionen/${enc(customerId)}`)
/**
* Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine); Antwort { projects: [das eine Projekt] }.
* Terminplaner im Modus on: beim Aktivieren zusaetzlich textformDate (JJJJ-MM-TT) und textformChannel (chat | email).
* Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine | shop); Antwort { projects: [das eine Projekt] }.
* Terminplaner und Webshop im Modus on: beim Aktivieren zusaetzlich textformDate (JJJJ-MM-TT) und textformChannel (chat | email).
*/
export const setFeature = (customerId, key, { projectId, action, note, textformDate, textformChannel }) =>
request(`/funktionen/${enc(customerId)}/${enc(key)}`, { method: 'POST', body: featureBody({ projectId, action, note, textformDate, textformChannel }) })
@@ -104,6 +104,14 @@ export const setNewsFeature = (customerId, body) => setFeature(customerId, 'news
/** Einbauanleitung des Terminplaners: { stand, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis } (Aufbereitung: panelModell). */
export const getTermineAnleitung = (customerId, projectId) => request(anleitungPfad(customerId, projectId))
// --- Webshop (Anfrage des Kunden, Stripe-Stand, Kennzahlen) ---
/** { projects: [{ projectId, projectName, state, anfrage, stripe, geoeffnet, zahlen }] } (Aufbereitung: funktionenView) */
export const getShop = (customerId) => request(`/shop/${enc(customerId)}`)
/** Anfrage annehmen oder ablehnen: body { projectId, entscheidung: 'annehmen' | 'ablehnen', begruendung } => { ok, anfrage, mailFehler? } */
export const entscheideShopAnfrage = (customerId, { projectId, entscheidung, begruendung }) =>
request(`/shop/${enc(customerId)}/anfrage`, { method: 'POST', body: entscheidungBody({ projectId, entscheidung, begruendung }) })
// --- Oeffnungszeiten live (Funktion zeiten): Ersatz-HTML und Team-Einstellungen ---
/** Ersatz-HTML (Wochentabelle) eines Plans fuer den Einbau: { html }; 404 unbekannter Plan, 409 "Der Kunde hat noch keine Zeiten eingetragen." */
+19
View File
@@ -0,0 +1,19 @@
// Ein Aufruf hoechstens alle `maxAgeMs`: ein laufender Aufruf wird geteilt, danach liefert die
// Funktion bis zum Ablauf null (uebersprungen). Nach einem Fehler darf sofort neu versucht
// werden (sonst verschwand der Hinweis und der Abgleich fiel eine Minute lang still aus).
// `now` nur fuer Tests.
export function throttleRuns(run, maxAgeMs) {
let pending = null
let doneAt = 0
return ({ now = Date.now() } = {}) => {
if (pending) return pending
if (doneAt && now - doneAt < maxAgeMs) return Promise.resolve(null)
pending = Promise.resolve()
.then(run)
.then(
(result) => { doneAt = Date.now(); pending = null; return result },
(err) => { doneAt = 0; pending = null; throw err }
)
return pending
}
}