Merge remote-tracking branch 'origin/test' into feature/oeffnungszeiten
Zusammenführung von TP2 und Webshop im Ticketsystem (origin/test00fa229) 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-Commits0a2eab8und00fa229): 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:
commit
8319084930
19 files changed
+906
-165
No files matched your search
@@ -99,6 +99,9 @@ export const DOCUMENTS = [
|
|||||||
{ key: "leistungsbeschreibung-terminplaner", title: "Leistungsbeschreibung Terminplaner", liveUrl: "https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html" },
|
{ key: "leistungsbeschreibung-terminplaner", title: "Leistungsbeschreibung Terminplaner", liveUrl: "https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html" },
|
||||||
{ key: "avv-terminplaner", title: "AVV Terminplaner (Auftragsverarbeitung)", liveUrl: "https://project.webklar.com/rechtliches/avv-terminplaner.html" },
|
{ key: "avv-terminplaner", title: "AVV Terminplaner (Auftragsverarbeitung)", liveUrl: "https://project.webklar.com/rechtliches/avv-terminplaner.html" },
|
||||||
{ key: "widerrufsbelehrung-funktionen", title: "Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)", liveUrl: "https://project.webklar.com/rechtliches/widerrufsbelehrung-funktionen.html" },
|
{ key: "widerrufsbelehrung-funktionen", title: "Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)", liveUrl: "https://project.webklar.com/rechtliches/widerrufsbelehrung-funktionen.html" },
|
||||||
|
// Webshop: gleiches Muster wie beim Terminplaner (Alias-Seite ohne Fassungsdatum)
|
||||||
|
{ key: "leistungsbeschreibung-webshop", title: "Leistungsbeschreibung Webshop", liveUrl: "https://project.webklar.com/rechtliches/leistungsbeschreibung-webshop.html" },
|
||||||
|
{ key: "avv-webshop", title: "AVV Webshop", liveUrl: "https://project.webklar.com/rechtliches/avv-webshop.html" },
|
||||||
// Oeffnungszeiten live: Schluessel hoechstens 36 Zeichen (WOMS-Grenze fuer Dokument-IDs); die Fassungen (PDF/HTML) laedt der Inhaber hoch
|
// Oeffnungszeiten live: Schluessel hoechstens 36 Zeichen (WOMS-Grenze fuer Dokument-IDs); die Fassungen (PDF/HTML) laedt der Inhaber hoch
|
||||||
{ key: "leistungsbeschreibung-zeiten", title: "Leistungsbeschreibung Öffnungszeiten live", liveUrl: "https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html" },
|
{ key: "leistungsbeschreibung-zeiten", title: "Leistungsbeschreibung Öffnungszeiten live", liveUrl: "https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html" },
|
||||||
].map((d, i) => ({ ...d, sort: i + 1 }));
|
].map((d, i) => ({ ...d, sort: i + 1 }));
|
||||||
|
|||||||
@@ -1,28 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { FaPlus, FaLink } from 'react-icons/fa6'
|
import { FaPlus, FaLink } from 'react-icons/fa6'
|
||||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||||
import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi'
|
import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi'
|
||||||
import { isOutsideOrg, assignSummary } from '../lib/projectAssign'
|
import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign'
|
||||||
|
import { slugify } from '../lib/subdomain'
|
||||||
import AssignedProjectCard from './AssignedProjectCard'
|
import AssignedProjectCard from './AssignedProjectCard'
|
||||||
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
|
import { fetchServices, completeAssignment } from '../lib/projectKindsApi'
|
||||||
import { kindLabelFor } from '../lib/assignFlow'
|
import { kindLabelFor } from '../lib/assignFlow'
|
||||||
|
|
||||||
function slugify(v) {
|
|
||||||
return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht
|
|
||||||
// archiviert, keine Vorlage und noch nicht an diesem Ticket sind
|
|
||||||
function assignableProjects(all, ticket, assignedIds) {
|
|
||||||
return (all || []).filter((p) =>
|
|
||||||
p.repoFullName &&
|
|
||||||
!assignedIds.has(p.$id) &&
|
|
||||||
p.status !== 'archived' &&
|
|
||||||
!p.isTemplate &&
|
|
||||||
(!p.customerId || p.customerId === ticket.customerId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle.
|
// Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle.
|
||||||
// Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt.
|
// Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt.
|
||||||
export default function TicketProjects({ ticket }) {
|
export default function TicketProjects({ ticket }) {
|
||||||
@@ -57,7 +42,7 @@ export default function TicketProjects({ ticket }) {
|
|||||||
setImporting(true)
|
setImporting(true)
|
||||||
setImportHint('')
|
setImportHint('')
|
||||||
try {
|
try {
|
||||||
await importNewRepos()
|
await importNewReposThrottled()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`)
|
if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -73,10 +58,11 @@ export default function TicketProjects({ ticket }) {
|
|||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
await action()
|
await action()
|
||||||
await load()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Aktion fehlgeschlagen')
|
setError(err.message || 'Aktion fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
// Auch nach einem Fehler neu laden: das Repo kann trotzdem entstanden sein
|
||||||
|
try { await load() } catch { /* Anzeige bleibt wie sie ist */ }
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +135,10 @@ export default function TicketProjects({ ticket }) {
|
|||||||
|
|
||||||
const create = (e) => {
|
const create = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (!slugify(createForm.subdomain)) {
|
||||||
|
setError('Bitte eine gültige Subdomain eingeben (Buchstaben, Ziffern, Bindestrich).')
|
||||||
|
return
|
||||||
|
}
|
||||||
run(async () => {
|
run(async () => {
|
||||||
const subdomain = slugify(createForm.subdomain)
|
const subdomain = slugify(createForm.subdomain)
|
||||||
await createProjectFromTemplate({
|
await createProjectFromTemplate({
|
||||||
@@ -157,6 +147,8 @@ export default function TicketProjects({ ticket }) {
|
|||||||
subdomain,
|
subdomain,
|
||||||
customerId: ticket.customerId || '',
|
customerId: ticket.customerId || '',
|
||||||
ticketId: ticket.$id,
|
ticketId: ticket.$id,
|
||||||
|
// Aus der Website-Vorlage mit Subdomain: die Art steht fest (sonst "Art fehlt")
|
||||||
|
kind: 'website',
|
||||||
})
|
})
|
||||||
setCreateForm({ displayName: '', subdomain: '' })
|
setCreateForm({ displayName: '', subdomain: '' })
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
|
|||||||
@@ -3,18 +3,19 @@ import { Link } from 'react-router-dom'
|
|||||||
import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6'
|
import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6'
|
||||||
import { Card, Badge, Button, EmptyState } from '../ui'
|
import { Card, Badge, Button, EmptyState } from '../ui'
|
||||||
import { copyText } from '../CopyableCredential'
|
import { copyText } from '../CopyableCredential'
|
||||||
import { getFunktionen, setFeature, getTermineAnleitung } from '../../lib/teamApi'
|
import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi'
|
||||||
import {
|
import {
|
||||||
FUNKTIONEN, ZUSTAND, AKTION_LABEL, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText,
|
FUNKTIONEN, ZUSTAND, AKTION_LABEL, funktionOf, aktionen, mergeFunktion, siteKeyOf, abgeschaltetText,
|
||||||
hindernisText, panelModell, anleitungOffen, textformNoetig, textformAnsicht, textformBereit,
|
hindernisText, panelModell, anleitungOffen, textformNoetig, textformAnsicht, textformBereit, speicherStatus,
|
||||||
|
anfrageZeilen, anfrageStatus, stripeText, stripeTon, shopHatEintrag, begruendungOk, BEGRUENDUNG_MAX, entscheidungStatus,
|
||||||
} from '../../lib/funktionenView'
|
} from '../../lib/funktionenView'
|
||||||
import { CHANNELS, channelLabel } from '../../lib/analyseView'
|
import { CHANNELS, channelLabel } from '../../lib/analyseView'
|
||||||
import { berlinYmd } from '../../lib/format'
|
import { berlinYmd, fmtDateTime } from '../../lib/format'
|
||||||
import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView'
|
import { zeitenStatusText, mergeTeam, abschaltMeldung } from '../../lib/zeitenView'
|
||||||
import ZeitenPanel from './ZeitenPanel'
|
import ZeitenPanel from './ZeitenPanel'
|
||||||
import '../../styles/admin-areas.css'
|
import '../../styles/admin-areas.css'
|
||||||
|
|
||||||
// Reiter "Funktionen": News-Pinnwand und Terminplaner je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
|
// Reiter "Funktionen": News-Pinnwand, Terminplaner und Webshop je Projekt an/aus wie im frueheren Admin-Panel (Kundenliste, Zeile "Funktionen")
|
||||||
const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text)
|
const short = (text) => (text.length > 60 ? `${text.slice(0, 60)}…` : text)
|
||||||
|
|
||||||
// Site-Key des Terminplaners (steht im Einbau-Code der Kundenwebsite): kleine Schrift mit fester Breite und Kopierknopf
|
// Site-Key des Terminplaners (steht im Einbau-Code der Kundenwebsite): kleine Schrift mit fester Breite und Kopierknopf
|
||||||
@@ -115,10 +116,10 @@ function AnleitungPanel({ customerId, projectId, feature, reloadKey }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zustimmung des Kunden in Textform (Terminplaner im Modus on): Haken, Datum, Weg und die Textvorlage fuer den Chat. Die Pflichtangaben
|
// Zustimmung des Kunden in Textform (Terminplaner und Webshop im Modus on): Haken, Datum, Weg und die Textvorlage fuer den Chat. Die Pflichtangaben
|
||||||
// prueft textformBereit (funktionenView); das Portal prueft sie noch einmal. Links nur als geprueftes https (textformAnsicht).
|
// prueft textformBereit (funktionenView); das Portal prueft sie noch einmal. Links nur als geprueftes https (textformAnsicht).
|
||||||
function TextformPanel({ termine, value, onChange, projectId, today, disabled }) {
|
function TextformPanel({ funktion, feld, value, onChange, projectId, today, disabled }) {
|
||||||
const ansicht = textformAnsicht(termine)
|
const ansicht = textformAnsicht(feld, funktion)
|
||||||
const { label, links, vorlage } = ansicht
|
const { label, links, vorlage } = ansicht
|
||||||
return (
|
return (
|
||||||
<div className="area-row funktionen-panel">
|
<div className="area-row funktionen-panel">
|
||||||
@@ -141,7 +142,7 @@ function TextformPanel({ termine, value, onChange, projectId, today, disabled })
|
|||||||
<span>Weg:</span>
|
<span>Weg:</span>
|
||||||
{CHANNELS.map((c) => (
|
{CHANNELS.map((c) => (
|
||||||
<label key={c} className="area-inline">
|
<label key={c} className="area-inline">
|
||||||
<input type="radio" name={`termine-weg-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} />
|
<input type="radio" name={`textform-weg-${funktion}-${projectId}`} value={c} checked={value.channel === c} disabled={disabled} onChange={() => onChange({ ...value, channel: c })} />
|
||||||
{channelLabel(c)}
|
{channelLabel(c)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -160,11 +161,80 @@ function TextformPanel({ termine, value, onChange, projectId, today, disabled })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Webshop je Projekt: Anfrage des Kunden (annehmen / ablehnen mit Begruendung), Stripe-Stand und Kennzahlen. Die Entscheidung geht ans Portal
|
||||||
|
// (POST /shop/:customerId/anfrage); danach laden Funktionen und Shop neu (onDecided). Texte sind ausschliesslich React-Textknoten.
|
||||||
|
function ShopPanel({ customerId, shop, onDecided }) {
|
||||||
|
const [frage, setFrage] = useState(null) // null | 'annehmen' | 'ablehnen'
|
||||||
|
const [begruendung, setBegruendung] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [status, setStatus] = useState(null) // { ok, text }
|
||||||
|
const a = shop.anfrage
|
||||||
|
const [tone, label] = anfrageStatus(a?.status)
|
||||||
|
const stripe = shop.stripe || { accountId: false, status: null }
|
||||||
|
const entscheiden = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setStatus(null)
|
||||||
|
try {
|
||||||
|
const antwort = await entscheideShopAnfrage(customerId, { projectId: shop.projectId, entscheidung: frage, begruendung })
|
||||||
|
setFrage(null)
|
||||||
|
setBegruendung('')
|
||||||
|
setStatus(entscheidungStatus(antwort))
|
||||||
|
onDecided().catch(() => {}) // Neuladen scheitert hoechstens leise; die Entscheidung ist gespeichert
|
||||||
|
} catch (err) {
|
||||||
|
setFrage(null)
|
||||||
|
setStatus({ ok: false, text: err.message })
|
||||||
|
onDecided().catch(() => {})
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ablehnenFehlt = frage === 'ablehnen' && !begruendungOk(begruendung)
|
||||||
|
return (
|
||||||
|
<div className="area-row funktionen-panel">
|
||||||
|
<div className="funktionen-panel-head">
|
||||||
|
<strong>Webshop: {shop.projectName}</strong>
|
||||||
|
{a && <Badge tone={tone}>Anfrage: {label}</Badge>}
|
||||||
|
<Badge tone={shop.geoeffnet ? 'ok' : 'muted'}>{shop.geoeffnet ? 'Geöffnet' : 'Geschlossen'}</Badge>
|
||||||
|
<Badge tone={stripeTon(stripe.status, !!stripe.accountId)}>Stripe: {stripeText(stripe.status, !!stripe.accountId)}</Badge>
|
||||||
|
</div>
|
||||||
|
{a && (
|
||||||
|
<dl className="area-kv">
|
||||||
|
{anfrageZeilen(a.anfrage).map((z) => (
|
||||||
|
<Fragment key={z.label}><dt>{z.label}</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{z.text}</dd></Fragment>
|
||||||
|
))}
|
||||||
|
<dt>Eingegangen</dt><dd>{a.at ? fmtDateTime(a.at) : '–'}</dd>
|
||||||
|
{a.entschiedenAt && <><dt>Entschieden</dt><dd>{fmtDateTime(a.entschiedenAt)}</dd></>}
|
||||||
|
{a.begruendung && <><dt>Begründung</dt><dd style={{ whiteSpace: 'pre-wrap' }}>{a.begruendung}</dd></>}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
<div className="area-muted">Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}</div>
|
||||||
|
{a?.status === 'offen' && (
|
||||||
|
frage ? (
|
||||||
|
<div className="area-inline">
|
||||||
|
<span>{frage === 'annehmen' ? 'Anfrage annehmen? Der Kunde bekommt eine Mail.' : 'Anfrage ablehnen? Der Kunde bekommt die Begründung per Mail.'}</span>
|
||||||
|
{frage === 'ablehnen' && (
|
||||||
|
<textarea className="form-control" required rows={3} maxLength={BEGRUENDUNG_MAX} placeholder="Begründung (Pflicht)" value={begruendung} onChange={(e) => setBegruendung(e.target.value)} style={{ width: '100%' }} />
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="primary" onClick={entscheiden} disabled={busy || ablehnenFehlt}>{busy ? <FaSpinner className="spinner" /> : 'Ja'}</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setFrage(null)} disabled={busy}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="area-inline">
|
||||||
|
<Button size="sm" variant="primary" onClick={() => { setBegruendung(''); setFrage('annehmen') }}>Anfrage annehmen</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { setBegruendung(''); setFrage('ablehnen') }}>Ablehnen</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{status && <div className={status.ok ? 'area-message' : 'area-error'}>{status.text}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const LEERE_TEXTFORM = { confirmed: false, date: '', channel: '' }
|
const LEERE_TEXTFORM = { confirmed: false, date: '', channel: '' }
|
||||||
|
|
||||||
export default function CustomerFunktionen({ customerId }) {
|
export default function CustomerFunktionen({ customerId }) {
|
||||||
const [projects, setProjects] = useState(null)
|
const [projects, setProjects] = useState(null)
|
||||||
const [termine, setTermine] = useState(null) // Portal-Feld termine: { textformNoetig, avvVersion, links }
|
const [felder, setFelder] = useState({ termine: null, shop: null }) // Portal-Felder termine und shop: { textformNoetig, avvVersion, links }
|
||||||
const [textform, setTextform] = useState(LEERE_TEXTFORM)
|
const [textform, setTextform] = useState(LEERE_TEXTFORM)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [confirm, setConfirm] = useState(null) // { projectId, key, action }
|
const [confirm, setConfirm] = useState(null) // { projectId, key, action }
|
||||||
@@ -172,40 +242,50 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [status, setStatus] = useState(null) // { ok, text }
|
const [status, setStatus] = useState(null) // { ok, text }
|
||||||
const [panel, setPanel] = useState({}) // projectId -> true/false (Knopf "Anleitung"); ohne Eintrag entscheidet die Adresse (?anleitung=<projectId>)
|
const [panel, setPanel] = useState({}) // projectId -> true/false (Knopf "Anleitung"); ohne Eintrag entscheidet die Adresse (?anleitung=<projectId>)
|
||||||
|
const [shop, setShop] = useState([]) // GET /shop/:customerId => projects; Fehler nur im Webshop-Bereich, nicht in der Tabelle
|
||||||
|
const [shopFehler, setShopFehler] = useState(null)
|
||||||
const [panelReload, setPanelReload] = useState({}) // projectId -> Zaehler, nach jeder Aktion hochgezaehlt
|
const [panelReload, setPanelReload] = useState({}) // projectId -> Zaehler, nach jeder Aktion hochgezaehlt
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setError(null)
|
setError(null)
|
||||||
getFunktionen(customerId)
|
getFunktionen(customerId)
|
||||||
.then(({ projects: list, termine: feld }) => { setProjects(list || []); setTermine(feld || null) })
|
.then(({ projects: list, termine, shop: shopFeld }) => { setProjects(list || []); setFelder({ termine: termine || null, shop: shopFeld || null }) })
|
||||||
.catch((err) => setError(err.message || 'Funktionen konnten nicht geladen werden.'))
|
.catch((err) => setError(err.message || 'Funktionen konnten nicht geladen werden.'))
|
||||||
}, [customerId])
|
}, [customerId])
|
||||||
useEffect(() => { load() }, [load])
|
const loadShop = useCallback(() => {
|
||||||
|
setShopFehler(null)
|
||||||
|
return getShop(customerId)
|
||||||
|
.then(({ projects: list }) => setShop(Array.isArray(list) ? list : []))
|
||||||
|
.catch((err) => setShopFehler(err.message || 'Webshop-Daten konnten nicht geladen werden.'))
|
||||||
|
}, [customerId])
|
||||||
|
useEffect(() => { load(); loadShop() }, [load, loadShop])
|
||||||
|
const nachEntscheidung = () => Promise.all([getFunktionen(customerId).then(({ projects: list, termine, shop: shopFeld }) => { setProjects(list || []); setFelder({ termine: termine || null, shop: shopFeld || null }) }), loadShop()])
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
const { projectId, key, action } = confirm
|
const { projectId, key, action } = confirm
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
try {
|
try {
|
||||||
const mitTextform = textformNoetig(key, action, termine)
|
const mitTextform = textformNoetig(key, action, felder[key])
|
||||||
const { projects: changed, ersatzTicket } = await setFeature(customerId, key, {
|
const antwort = await setFeature(customerId, key, {
|
||||||
projectId, action, note: note.trim(),
|
projectId, action, note: note.trim(),
|
||||||
...(mitTextform ? { textformDate: textform.date, textformChannel: textform.channel } : {}),
|
...(mitTextform ? { textformDate: textform.date, textformChannel: textform.channel } : {}),
|
||||||
})
|
})
|
||||||
// Die Antwort enthaelt das ganze Projekt; uebernommen wird nur die geaenderte Funktion
|
// Die Antwort enthaelt das ganze Projekt; uebernommen wird nur die geaenderte Funktion
|
||||||
const updated = changed?.[0]
|
const updated = antwort?.projects?.[0]
|
||||||
if (updated) setProjects((list) => mergeFunktion(list, updated.projectId, key, updated))
|
if (updated) setProjects((list) => mergeFunktion(list, updated.projectId, key, updated))
|
||||||
setConfirm(null)
|
setConfirm(null)
|
||||||
setNote('')
|
setNote('')
|
||||||
setTextform(LEERE_TEXTFORM)
|
setTextform(LEERE_TEXTFORM)
|
||||||
// Abschalten der Zeiten: das Portal meldet, ob das Ticket "Ersatz-Tabelle aktualisieren" angelegt wurde (bei 'fehler' warnen)
|
// Abschalten der Zeiten: das Portal meldet, ob das Ticket "Ersatz-Tabelle aktualisieren" angelegt wurde (bei 'fehler' warnen)
|
||||||
setStatus(key === 'zeiten' && action === 'disable' ? abschaltMeldung(ersatzTicket) : { ok: true, text: 'Gespeichert.' })
|
setStatus(key === 'zeiten' && action === 'disable' ? abschaltMeldung(antwort?.ersatzTicket) : speicherStatus(antwort))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setConfirm(null)
|
setConfirm(null)
|
||||||
setStatus({ ok: false, text: err.message })
|
setStatus({ ok: false, text: err.message })
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
// Version und Hindernis der Anleitung haengen vom Zustand ab: Panel dieser Zeile neu laden
|
// Version und Hindernis der Anleitung haengen vom Zustand ab: Panel dieser Zeile neu laden
|
||||||
|
if (key === 'shop') loadShop()
|
||||||
if (key === 'termine') setPanelReload((r) => ({ ...r, [projectId]: (r[projectId] || 0) + 1 }))
|
if (key === 'termine') setPanelReload((r) => ({ ...r, [projectId]: (r[projectId] || 0) + 1 }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,8 +310,8 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
|
const isConfirming = confirm?.projectId === p.projectId && confirm?.key === f.key
|
||||||
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
|
const hatAnleitung = f.key === 'termine' && feature.state !== 'off'
|
||||||
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
|
const anleitungOffenHier = hatAnleitung && panelOffen(p.projectId)
|
||||||
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, termine)
|
const braucheTextform = isConfirming && textformNoetig(f.key, confirm.action, felder[f.key])
|
||||||
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, termine)
|
const textformFehlt = braucheTextform && !textformBereit({ ...textform, today: heute }, felder[f.key])
|
||||||
const hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off'
|
const hatZeitenPanel = f.key === 'zeiten' && feature.state !== 'off'
|
||||||
const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : ''
|
const zeitenText = f.key === 'zeiten' ? zeitenStatusText(feature) : ''
|
||||||
return (
|
return (
|
||||||
@@ -286,7 +366,7 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{braucheTextform && (
|
{braucheTextform && (
|
||||||
<TextformPanel termine={termine} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
|
<TextformPanel funktion={f.key} feld={felder[f.key]} value={textform} onChange={setTextform} projectId={p.projectId} today={heute} disabled={busy} />
|
||||||
)}
|
)}
|
||||||
{hatZeitenPanel && (
|
{hatZeitenPanel && (
|
||||||
<ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} />
|
<ZeitenPanel customerId={customerId} projectId={p.projectId} feature={feature} onSaved={teamGespeichert} onReload={load} />
|
||||||
@@ -299,6 +379,14 @@ export default function CustomerFunktionen({ customerId }) {
|
|||||||
}))}
|
}))}
|
||||||
</div>
|
</div>
|
||||||
{status && <div className={status.ok ? 'area-message' : 'area-error'} style={{ margin: 12 }}>{status.text}</div>}
|
{status && <div className={status.ok ? 'area-message' : 'area-error'} style={{ margin: 12 }}>{status.text}</div>}
|
||||||
|
{shopFehler && <div className="area-error" style={{ margin: 12 }}>{shopFehler}</div>}
|
||||||
|
{shop.filter(shopHatEintrag).length > 0 && (
|
||||||
|
<div className="area-rows">
|
||||||
|
{shop.filter(shopHatEintrag).map((s) => (
|
||||||
|
<ShopPanel key={s.projectId} customerId={customerId} shop={s} onDecided={nachEntscheidung} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -59,5 +59,18 @@ export function useWebsiteProjects() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { projects, loading, error, fetchAllProjects, fetchByTicketId, assignProjects, unassignProject }
|
// Projektliste "Kein Kunde": Kunde UND Ticket entfernen (bewusste Auswahl, mit Rueckfrage)
|
||||||
|
const clearCustomer = async (projectId) => {
|
||||||
|
try {
|
||||||
|
await databases.updateDocument(
|
||||||
|
DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId,
|
||||||
|
{ customerId: '', ticketId: '', updatedAt: new Date().toISOString() }
|
||||||
|
)
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { projects, loading, error, fetchAllProjects, fetchByTicketId, assignProjects, unassignProject, clearCustomer }
|
||||||
}
|
}
|
||||||
+140
-16
@@ -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.
|
// Einarbeiten der Portal-Antwort. Rein, unter Node testbar.
|
||||||
|
|
||||||
import { fmtYmd, fmtBerlin } from './format'
|
import { fmtYmd, fmtBerlin } from './format'
|
||||||
@@ -24,6 +24,16 @@ export const FUNKTIONEN = [
|
|||||||
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
|
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',
|
key: 'zeiten',
|
||||||
label: 'Öffnungszeiten live',
|
label: 'Öffnungszeiten live',
|
||||||
@@ -68,12 +78,12 @@ export function abgeschaltetText(feature) {
|
|||||||
export const funktionOf = (project, key) => project?.features?.[key] || { state: 'off' }
|
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 ''.
|
* und nur, wenn das Portal einen nennt (feature.siteKey); sonst ''.
|
||||||
*/
|
*/
|
||||||
export function siteKeyOf(feature, key) {
|
export function siteKeyOf(feature, key) {
|
||||||
const wert = feature?.siteKey
|
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). */
|
/** 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
|
* 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
|
* 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
|
* "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).
|
* 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').
|
* (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 } = {}) {
|
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 === 'ordered' || state === 'active') return ['disable']
|
||||||
if (state === 'setup') {
|
if (state === 'setup') {
|
||||||
const abnahme = key === 'zeiten' && hindernis ? [] : ['abnahme']
|
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))
|
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). */
|
/** Funktionen, deren Aktivierung durch das Team die Zustimmung des Kunden in Textform brauchen kann (Portal: TEXTFORM_KEYS). */
|
||||||
export const textformNoetig = (key, action, termine) => key === 'termine' && action === 'activate' && termine?.textformNoetig === true
|
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. */
|
/** Kaestchentext (wie im Portal-Nachweis, ohne Datum und Weg); die beiden Links setzt die Komponente. */
|
||||||
export const termineTextformLabel = (version) => ({
|
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.`,
|
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). */
|
/** Textvorlage fuer den Chat (Kopierknopf); links = die versionierten Adressen aus dem Portal (termine.links). */
|
||||||
export const termineChatTemplate = (version, 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.`
|
`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 ''. */
|
/** Nur geprüfte https-Adressen werden Links; alles andere ''. */
|
||||||
const httpsUrl = (wert) => {
|
const httpsUrl = (wert) => {
|
||||||
if (typeof wert !== 'string') return ''
|
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 ''.
|
* vorlage nur, wenn Fassung und beide Adressen da sind (eine Vorlage mit leeren Adressen geht nie an den Kunden), sonst ''.
|
||||||
*/
|
*/
|
||||||
export function textformAnsicht(termine) {
|
export function textformAnsicht(feld, key = 'termine') {
|
||||||
const version = typeof termine?.avvVersion === 'string' ? termine.avvVersion : ''
|
const shop = key === 'shop'
|
||||||
const links = { lb: httpsUrl(termine?.links?.lb), avv: httpsUrl(termine?.links?.avv) }
|
const version = typeof feld?.avvVersion === 'string' ? feld.avvVersion : ''
|
||||||
|
const links = { lb: httpsUrl(feld?.links?.lb), avv: httpsUrl(feld?.links?.avv) }
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
links,
|
links,
|
||||||
label: termineTextformLabel(version),
|
label: (shop ? shopTextformLabel : termineTextformLabel)(version),
|
||||||
vorlage: version && links.lb && links.avv ? termineChatTemplate(version, links) : '',
|
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. */
|
/** 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) {
|
export function textformBereit({ confirmed, date, channel, today }, feld) {
|
||||||
const minDate = typeof termine?.avvVersion === 'string' ? termine.avvVersion : ''
|
const minDate = typeof feld?.avvVersion === 'string' ? feld.avvVersion : ''
|
||||||
if (!minDate) return false
|
if (!minDate) return false
|
||||||
return canSubmitTextform({ confirmed, date, channel, today, minDate })
|
return canSubmitTextform({ confirmed, date, channel, today, minDate })
|
||||||
}
|
}
|
||||||
@@ -240,3 +271,96 @@ export function featureBody({ projectId, action, note, textformDate, textformCha
|
|||||||
}
|
}
|
||||||
return body
|
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.' }
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { account } from './appwrite'
|
import { account } from './appwrite'
|
||||||
|
import { throttleRuns } from './throttleRuns'
|
||||||
|
|
||||||
const PROJECT_ADMIN_URL =
|
const PROJECT_ADMIN_URL =
|
||||||
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
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' })
|
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 }) {
|
export async function assignWebsiteProject(id, { customerId, ticketId }) {
|
||||||
return adminFetch(`/api/admin/website-projects/${id}/assign`, {
|
return adminFetch(`/api/admin/website-projects/${id}/assign`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -6,8 +6,34 @@ export function isOutsideOrg(repoFullName) {
|
|||||||
return org.toLowerCase() !== GITEA_ORG.toLowerCase()
|
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) {
|
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) {
|
export function assignSummary(projectName, result) {
|
||||||
|
|||||||
@@ -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
@@ -5,18 +5,21 @@ const UMLAUT_MAP = {
|
|||||||
ß: 'ss',
|
ß: '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) {
|
export function suggestSubdomain(customerName, projectName) {
|
||||||
const base = (customerName || '').trim() || (projectName || '').trim()
|
const base = (customerName || '').trim() || (projectName || '').trim()
|
||||||
if (!base) return ''
|
return slugify(base)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
+11
-3
@@ -1,7 +1,7 @@
|
|||||||
import { account } from './appwrite'
|
import { account } from './appwrite'
|
||||||
import { createFinanceRequest } from './financeRequest'
|
import { createFinanceRequest } from './financeRequest'
|
||||||
import { createJwtCache } from './serverpulsAuth'
|
import { createJwtCache } from './serverpulsAuth'
|
||||||
import { anleitungPfad, featureBody } from './funktionenView'
|
import { anleitungPfad, featureBody, entscheidungBody } from './funktionenView'
|
||||||
import { ersatzPfad, zeitenTeamPfad } from './zeitenView'
|
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)}`)
|
export const getFunktionen = (customerId) => request(`/funktionen/${enc(customerId)}`)
|
||||||
/**
|
/**
|
||||||
* Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine); Antwort { projects: [das eine Projekt] }.
|
* Aktivieren (ohne Zahlung) oder Abschalten einer Funktion (key: news | termine | shop); Antwort { projects: [das eine Projekt] }.
|
||||||
* Terminplaner im Modus on: beim Aktivieren zusaetzlich textformDate (JJJJ-MM-TT) und textformChannel (chat | email).
|
* 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 }) =>
|
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 }) })
|
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). */
|
/** Einbauanleitung des Terminplaners: { stand, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis } (Aufbereitung: panelModell). */
|
||||||
export const getTermineAnleitung = (customerId, projectId) => request(anleitungPfad(customerId, projectId))
|
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 ---
|
// --- 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." */
|
/** Ersatz-HTML (Wochentabelle) eines Plans fuer den Einbau: { html }; 404 unbekannter Plan, 409 "Der Kunde hat noch keine Zeiten eingetragen." */
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,13 @@ import { useAuth } from '../context/AuthContext'
|
|||||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||||
import { fetchServices, completeAssignment, fetchProject } from '../lib/projectKindsApi'
|
import { fetchServices, completeAssignment, fetchProject } from '../lib/projectKindsApi'
|
||||||
import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from '../lib/assignFlow'
|
import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from '../lib/assignFlow'
|
||||||
import { suggestSubdomain } from '../lib/subdomain'
|
import { suggestSubdomain, slugify } from '../lib/subdomain'
|
||||||
import { isArchived } from '../lib/leads'
|
import { isArchived } from '../lib/leads'
|
||||||
import { PageHeader, Card, Button, Field } from '../components/ui'
|
import { PageHeader, Card, Button, Field } from '../components/ui'
|
||||||
import { CreateCustomerForm } from './CustomersPage'
|
import { CreateCustomerForm } from './CustomersPage'
|
||||||
|
|
||||||
const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||||
const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString('de-DE') : '')
|
const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString('de-DE') : '')
|
||||||
const slugify = (v) =>
|
|
||||||
String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63)
|
|
||||||
|
|
||||||
const STEP_TITLES = {
|
const STEP_TITLES = {
|
||||||
kind: 'Was ist das?',
|
kind: 'Was ist das?',
|
||||||
@@ -95,11 +93,15 @@ export default function AssignProjectPage() {
|
|||||||
const openTickets = useMemo(() => openTicketsFor(tickets, state.customerId), [tickets, state.customerId])
|
const openTickets = useMemo(() => openTicketsFor(tickets, state.customerId), [tickets, state.customerId])
|
||||||
|
|
||||||
const isWebsiteWithCustomer = state.kind === 'website' && state.forCustomer
|
const isWebsiteWithCustomer = state.kind === 'website' && state.forCustomer
|
||||||
|
// Hat das Projekt schon eine Website (z. B. ueber "Neues Projekt" mit Subdomain angelegt), bleibt
|
||||||
|
// sie wie sie ist: kein Vorschlag, kein erneutes Einschalten (das benannte sie sonst um und
|
||||||
|
// stellte eine oeffentliche Website auf Login-Preview).
|
||||||
|
const hasPreviewAlready = Boolean(project?.subdomain)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.step === 'summary' && isWebsiteWithCustomer && !subdomain) {
|
if (state.step === 'summary' && isWebsiteWithCustomer && !hasPreviewAlready && !subdomain) {
|
||||||
setSubdomain(suggestSubdomain('', repoName))
|
setSubdomain(suggestSubdomain('', repoName))
|
||||||
}
|
}
|
||||||
}, [state.step, isWebsiteWithCustomer, subdomain, repoName])
|
}, [state.step, isWebsiteWithCustomer, hasPreviewAlready, subdomain, repoName])
|
||||||
|
|
||||||
const patch = (p) => setState((s) => ({ ...s, ...p }))
|
const patch = (p) => setState((s) => ({ ...s, ...p }))
|
||||||
const go = (step) => { setError(null); patch({ step }) }
|
const go = (step) => { setError(null); patch({ step }) }
|
||||||
@@ -141,7 +143,7 @@ export default function AssignProjectPage() {
|
|||||||
const { topic, requestedBy, startDate, details } = state.newTicket
|
const { topic, requestedBy, startDate, details } = state.newTicket
|
||||||
body.newTicket = { topic, requestedBy, startDate, details }
|
body.newTicket = { topic, requestedBy, startDate, details }
|
||||||
}
|
}
|
||||||
if (isWebsiteWithCustomer && previewOn && subdomain.trim()) {
|
if (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && subdomain.trim()) {
|
||||||
body.preview = { subdomain: slugify(subdomain) }
|
body.preview = { subdomain: slugify(subdomain) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,7 +355,14 @@ export default function AssignProjectPage() {
|
|||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
{isWebsiteWithCustomer && (
|
{isWebsiteWithCustomer && hasPreviewAlready && (
|
||||||
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
|
{['deployed', 'ready'].includes(String(project.status || '').toLowerCase())
|
||||||
|
? <>Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert.</>
|
||||||
|
: <>Preview ist unter https://{project.subdomain}.project.webklar.com eingerichtet (Status: {project.status || 'unbekannt'}). Neu bauen geht im Projekt über „Preview".</>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isWebsiteWithCustomer && !hasPreviewAlready && (
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<label className="flex gap-2" style={{ alignItems: 'center' }}>
|
<label className="flex gap-2" style={{ alignItems: 'center' }}>
|
||||||
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
|
<input type="checkbox" checked={previewOn} onChange={(e) => setPreviewOn(e.target.checked)} />
|
||||||
@@ -366,7 +375,7 @@ export default function AssignProjectPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && previewOn && !slugify(subdomain))} onClick={finish}>
|
<Button variant="primary" disabled={busy || !canFinish(state) || (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && !slugify(subdomain))} onClick={finish}>
|
||||||
{busy ? <FaSpinner className="spinner" /> : <><FaCheck /> Abschließen</>}
|
{busy ? <FaSpinner className="spinner" /> : <><FaCheck /> Abschließen</>}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+92
-76
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import PreviewBlock from '../components/PreviewBlock'
|
import PreviewBlock from '../components/PreviewBlock'
|
||||||
import {
|
import {
|
||||||
@@ -24,19 +24,14 @@ import {
|
|||||||
updateWebsiteProject,
|
updateWebsiteProject,
|
||||||
archiveWebsiteProject,
|
archiveWebsiteProject,
|
||||||
unarchiveWebsiteProject,
|
unarchiveWebsiteProject,
|
||||||
|
importNewReposThrottled,
|
||||||
} from '../lib/projectAdminApi'
|
} from '../lib/projectAdminApi'
|
||||||
import { assignSummary } from '../lib/projectAssign'
|
import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign'
|
||||||
|
import { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList'
|
||||||
|
import { slugify } from '../lib/subdomain'
|
||||||
import PreviewLinkButton from '../components/PreviewLinkButton'
|
import PreviewLinkButton from '../components/PreviewLinkButton'
|
||||||
import GiteaLinkButton from '../components/GiteaLinkButton'
|
import GiteaLinkButton from '../components/GiteaLinkButton'
|
||||||
|
|
||||||
function slugify(value) {
|
|
||||||
return String(value || '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9-]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORKORDER_FILTERS = { limit: 500 }
|
const WORKORDER_FILTERS = { limit: 500 }
|
||||||
|
|
||||||
// Projekt-Typen (= "Template"/Hosting-Modus)
|
// Projekt-Typen (= "Template"/Hosting-Modus)
|
||||||
@@ -91,7 +86,7 @@ function isProjectReady(project) {
|
|||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects()
|
const { projects, loading, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects()
|
||||||
const { customers } = useCustomers()
|
const { customers } = useCustomers()
|
||||||
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
const { workorders } = useWorkorders(WORKORDER_FILTERS)
|
||||||
const { employees } = useEmployees()
|
const { employees } = useEmployees()
|
||||||
@@ -122,8 +117,15 @@ export default function ProjectsPage() {
|
|||||||
const [editError, setEditError] = useState('')
|
const [editError, setEditError] = useState('')
|
||||||
const [archiveBusy, setArchiveBusy] = useState(false)
|
const [archiveBusy, setArchiveBusy] = useState(false)
|
||||||
|
|
||||||
|
// Erst die bekannte Liste zeigen, dann neue/archivierte Gitea-Repos abholen und neu laden -
|
||||||
|
// sonst fehlte ein frisch angelegtes Repo hier bis zum naechsten 10-Minuten-Lauf.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
fetchAllProjects()
|
fetchAllProjects()
|
||||||
|
importNewReposThrottled()
|
||||||
|
.then((r) => { if (r && !cancelled) fetchAllProjects() })
|
||||||
|
.catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ })
|
||||||
|
return () => { cancelled = true }
|
||||||
}, [fetchAllProjects])
|
}, [fetchAllProjects])
|
||||||
|
|
||||||
const customerMap = useMemo(() => {
|
const customerMap = useMemo(() => {
|
||||||
@@ -158,53 +160,31 @@ export default function ProjectsPage() {
|
|||||||
[employees]
|
[employees]
|
||||||
)
|
)
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
// Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin)
|
||||||
// Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin)
|
const counts = useMemo(() => projectCounts(projects, { isAdmin }), [projects, isAdmin])
|
||||||
const active = projects.filter((p) => !p.archived && !p.isTemplate)
|
|
||||||
return {
|
|
||||||
all: active.length,
|
|
||||||
unassigned: active.filter((p) => !p.customerId).length,
|
|
||||||
assigned: active.filter((p) => Boolean(p.customerId)).length,
|
|
||||||
archived: projects.filter((p) => p.archived).length,
|
|
||||||
templates: projects.filter((p) => p.isTemplate && !p.archived).length,
|
|
||||||
}
|
|
||||||
}, [projects])
|
|
||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
// Filter neben der Kategorie: Textsuche (Titel/Subdomain/Repo), Kunde, Mitarbeiter (ueber das
|
||||||
// 1) Kategorie (Dropdown)
|
// zugeordnete Ticket -> assignedTo)
|
||||||
let base
|
const pageMatches = useCallback((p) => {
|
||||||
if (filter === 'templates') base = projects.filter((p) => p.isTemplate && !p.archived)
|
if (!matchesSearch(p, search)) return false
|
||||||
else if (filter === 'archived') base = projects.filter((p) => p.archived)
|
if (customerFilter && p.customerId !== customerFilter) return false
|
||||||
else {
|
|
||||||
const active = projects.filter((p) => !p.archived && !p.isTemplate)
|
|
||||||
if (filter === 'unassigned') base = active.filter((p) => !p.customerId)
|
|
||||||
else if (filter === 'assigned') base = active.filter((p) => Boolean(p.customerId))
|
|
||||||
else base = active
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Textsuche (Titel/Subdomain/Repo)
|
|
||||||
const q = search.trim().toLowerCase()
|
|
||||||
if (q) {
|
|
||||||
base = base.filter((p) =>
|
|
||||||
[p.projectName, p.subdomain, p.repoFullName, p.giteaRepoName].some((v) =>
|
|
||||||
String(v || '').toLowerCase().includes(q)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Kunde
|
|
||||||
if (customerFilter) base = base.filter((p) => p.customerId === customerFilter)
|
|
||||||
|
|
||||||
// 4) Mitarbeiter (ueber das zugeordnete Ticket -> assignedTo)
|
|
||||||
if (employeeFilter) {
|
if (employeeFilter) {
|
||||||
base = base.filter((p) => {
|
const wo = p.ticketId ? workorderById[p.ticketId] : null
|
||||||
const wo = p.ticketId ? workorderById[p.ticketId] : null
|
if (!wo || wo.assignedTo !== employeeFilter) return false
|
||||||
return wo && wo.assignedTo === employeeFilter
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}, [search, customerFilter, employeeFilter, workorderById])
|
||||||
|
|
||||||
return base
|
const filteredProjects = useMemo(
|
||||||
}, [projects, filter, search, customerFilter, employeeFilter, workorderById])
|
() => projectsInView(projects, filter, { isAdmin }).filter(pageMatches),
|
||||||
|
[projects, filter, isAdmin, pageMatches]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Suchtreffer in ausgeblendeten Ansichten (Vorlagen, Archiv) - sonst wirkt ein Projekt verschwunden
|
||||||
|
const hiddenHits = useMemo(
|
||||||
|
() => (search.trim() ? hiddenMatches(projects, filter, pageMatches, { isAdmin }) : []),
|
||||||
|
[projects, filter, isAdmin, search, pageMatches]
|
||||||
|
)
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
const handleCreate = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -212,7 +192,7 @@ export default function ProjectsPage() {
|
|||||||
setCreateError('')
|
setCreateError('')
|
||||||
try {
|
try {
|
||||||
const result = await createProjectFromTemplate({
|
const result = await createProjectFromTemplate({
|
||||||
repoName: createForm.repoName || slugify(createForm.subdomain) || slugify(createForm.displayName),
|
repoName: slugify(createForm.repoName) || slugify(createForm.subdomain) || slugify(createForm.displayName),
|
||||||
displayName: createForm.displayName,
|
displayName: createForm.displayName,
|
||||||
subdomain: slugify(createForm.subdomain),
|
subdomain: slugify(createForm.subdomain),
|
||||||
customerId: createForm.customerId || '',
|
customerId: createForm.customerId || '',
|
||||||
@@ -230,6 +210,8 @@ export default function ProjectsPage() {
|
|||||||
if (result.projectId) navigate(`/projects/zuordnen/${result.projectId}`)
|
if (result.projectId) navigate(`/projects/zuordnen/${result.projectId}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
|
setCreateError(err.message || 'Projekt konnte nicht angelegt werden')
|
||||||
|
// Das Repo kann trotz Fehler entstanden sein - Liste trotzdem neu laden
|
||||||
|
fetchAllProjects()
|
||||||
} finally {
|
} finally {
|
||||||
setCreateLoading(false)
|
setCreateLoading(false)
|
||||||
}
|
}
|
||||||
@@ -253,13 +235,14 @@ export default function ProjectsPage() {
|
|||||||
setAssignBusyId(project.$id)
|
setAssignBusyId(project.$id)
|
||||||
setNotice('')
|
setNotice('')
|
||||||
if (customerId) {
|
if (customerId) {
|
||||||
const r = await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' })
|
const ticketId = ticketIdForCustomer(project, customerId, workorderById)
|
||||||
|
const r = await assignProjects([project.$id], { customerId, ticketId })
|
||||||
const one = r.results[0]
|
const one = r.results[0]
|
||||||
const summary = assignSummary(project.projectName, one.ok ? one.result : { error: one.error })
|
const summary = assignSummary(project.projectName, one.ok ? one.result : { error: one.error })
|
||||||
setNotice(summary.warnings.length ? `${summary.text} – ${summary.warnings.join(' – ')}` : summary.text)
|
setNotice(summary.warnings.length ? `${summary.text} – ${summary.warnings.join(' – ')}` : summary.text)
|
||||||
} else {
|
} else if (window.confirm(`Kunde von "${project.projectName}" entfernen? Der Kunde sieht das Projekt dann nicht mehr im Portal, das Ticket wird gelöst.`)) {
|
||||||
const r = await unassignProject(project.$id)
|
const r = await clearCustomer(project.$id)
|
||||||
if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
|
setNotice(r.success ? `"${project.projectName}" hat keinen Kunden mehr.` : 'Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
|
||||||
}
|
}
|
||||||
await fetchAllProjects()
|
await fetchAllProjects()
|
||||||
setAssignBusyId('')
|
setAssignBusyId('')
|
||||||
@@ -326,14 +309,21 @@ export default function ProjectsPage() {
|
|||||||
if (editForm.projectType !== 'project') {
|
if (editForm.projectType !== 'project') {
|
||||||
payload.subdomain = slugify(editForm.subdomain)
|
payload.subdomain = slugify(editForm.subdomain)
|
||||||
}
|
}
|
||||||
const result = await updateWebsiteProject(editProject.$id, payload)
|
const projectId = editProject.$id
|
||||||
|
const result = await updateWebsiteProject(projectId, payload)
|
||||||
setEditProject(null)
|
setEditProject(null)
|
||||||
setNotice(
|
let text = result.previewUrl
|
||||||
result.previewUrl
|
? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}`
|
||||||
? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}`
|
: 'Projekt aktualisiert.'
|
||||||
: 'Projekt aktualisiert.'
|
// Faellt das Projekt aus der aktuellen Ansicht (z. B. zur Vorlage gemacht), dorthin wechseln,
|
||||||
)
|
// wo es jetzt steht - sonst wirkt es verschwunden
|
||||||
await fetchAllProjects()
|
const fresh = (await fetchAllProjects()).find((p) => p.$id === projectId)
|
||||||
|
const view = viewShowing(fresh, filter, { isAdmin })
|
||||||
|
if (fresh && view !== filter) {
|
||||||
|
setFilter(view)
|
||||||
|
text += ` – „${fresh.projectName}“ steht jetzt unter „${VIEW_LABELS[view]}“.`
|
||||||
|
}
|
||||||
|
setNotice(text)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEditError(err.message || 'Projekt konnte nicht aktualisiert werden')
|
setEditError(err.message || 'Projekt konnte nicht aktualisiert werden')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -366,12 +356,13 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FILTER_OPTIONS = [
|
const FILTER_OPTIONS = [
|
||||||
{ value: 'all', label: `Alle Projekte (${counts.all})` },
|
{ value: 'all', label: `Aktive Projekte (${counts.all})` },
|
||||||
{ value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` },
|
{ value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` },
|
||||||
{ value: 'assigned', label: `Zugeordnet (${counts.assigned})` },
|
{ value: 'assigned', label: `Zugeordnet (${counts.assigned})` },
|
||||||
// Vorlagen-Ansicht nur fuer Admins
|
// Vorlagen-Ansicht nur fuer Admins
|
||||||
...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []),
|
...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []),
|
||||||
{ value: 'archived', label: `Archiviert (${counts.archived})` },
|
{ value: 'archived', label: `Archiviert (${counts.archived})` },
|
||||||
|
{ value: 'everything', label: `Alle inkl. Archiv (${counts.everything})` },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins
|
// Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins
|
||||||
@@ -459,6 +450,24 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hiddenHits.length > 0 && (
|
||||||
|
<div className="text-center mb-2" data-hidden-hits>
|
||||||
|
<span className="text-grey">Weitere Treffer: </span>
|
||||||
|
{hiddenHits.map((h, i) => (
|
||||||
|
<span key={h.view}>
|
||||||
|
{i > 0 && <span className="text-grey"> · </span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{ padding: 0, border: 'none', background: 'none', color: 'var(--accent)', textDecoration: 'underline', cursor: 'pointer', font: 'inherit' }}
|
||||||
|
onClick={() => setFilter(h.view)}
|
||||||
|
>
|
||||||
|
{h.count} unter „{VIEW_LABELS[h.view]}“
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||||
{error}
|
{error}
|
||||||
@@ -627,13 +636,9 @@ export default function ProjectsPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
value={createForm.subdomain}
|
value={createForm.subdomain}
|
||||||
onChange={(e) =>
|
// Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst
|
||||||
setCreateForm((p) => ({
|
// stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name.
|
||||||
...p,
|
onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))}
|
||||||
subdomain: e.target.value,
|
|
||||||
repoName: p.repoName || slugify(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="leer lassen = Projekt ohne Website"
|
placeholder="leer lassen = Projekt ohne Website"
|
||||||
/>
|
/>
|
||||||
<small className="text-grey">
|
<small className="text-grey">
|
||||||
@@ -747,12 +752,23 @@ export default function ProjectsPage() {
|
|||||||
? <><FaBoxOpen /> Reaktivieren</>
|
? <><FaBoxOpen /> Reaktivieren</>
|
||||||
: <><FaBoxArchive /> Repository archivieren</>}
|
: <><FaBoxArchive /> Repository archivieren</>}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn btn-dark" disabled={editLoading || archiveBusy}>
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-dark"
|
||||||
|
disabled={editLoading || archiveBusy || editProject.archived}
|
||||||
|
title={editProject.archived ? 'Archivierte Projekte erst reaktivieren' : undefined}
|
||||||
|
>
|
||||||
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
|
{editLoading ? 'Speichert...' : 'Speichern & synchronisieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{editProject.archived && (
|
||||||
|
<small className="text-grey" style={{ display: 'block', marginTop: 8 }}>
|
||||||
|
Archiviert: zum Bearbeiten oder Hosten erst reaktivieren.
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
{editForm.projectType !== 'project' && (
|
{/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */}
|
||||||
|
{editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
|
||||||
<div className="form-group" style={{ marginTop: 16 }}>
|
<div className="form-group" style={{ marginTop: 16 }}>
|
||||||
<label className="form-label">Preview</label>
|
<label className="form-label">Preview</label>
|
||||||
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
|
<PreviewBlock key={editProject.$id} projectId={editProject.$id} />
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ test('terminplaner_karten_vorhanden', () => {
|
|||||||
{ key: 'leistungsbeschreibung-terminplaner', title: 'Leistungsbeschreibung Terminplaner', sort: 7 },
|
{ key: 'leistungsbeschreibung-terminplaner', title: 'Leistungsbeschreibung Terminplaner', sort: 7 },
|
||||||
{ key: 'avv-terminplaner', title: 'AVV Terminplaner (Auftragsverarbeitung)', sort: 8 },
|
{ key: 'avv-terminplaner', title: 'AVV Terminplaner (Auftragsverarbeitung)', sort: 8 },
|
||||||
{ key: 'widerrufsbelehrung-funktionen', title: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', sort: 9 },
|
{ key: 'widerrufsbelehrung-funktionen', title: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', sort: 9 },
|
||||||
|
{ key: 'leistungsbeschreibung-webshop', title: 'Leistungsbeschreibung Webshop', sort: 10 },
|
||||||
|
{ key: 'avv-webshop', title: 'AVV Webshop', sort: 11 },
|
||||||
]
|
]
|
||||||
for (const e of erwartet) {
|
for (const e of erwartet) {
|
||||||
const d = DOCUMENTS.find((x) => x.key === e.key)
|
const d = DOCUMENTS.find((x) => x.key === e.key)
|
||||||
@@ -62,8 +64,8 @@ test('terminplaner_karten_vorhanden', () => {
|
|||||||
assert.ok(d.liveUrl.startsWith(base))
|
assert.ok(d.liveUrl.startsWith(base))
|
||||||
assert.ok(!/\d{4}-\d{2}-\d{2}/.test(d.liveUrl), 'Alias-Seite ohne Fassungsdatum')
|
assert.ok(!/\d{4}-\d{2}-\d{2}/.test(d.liveUrl), 'Alias-Seite ohne Fassungsdatum')
|
||||||
}
|
}
|
||||||
// Oeffnungszeiten live: Karte 10, hinter den Terminplaner-Karten; der Schluessel hat 28 Zeichen (WOMS-Grenze 36, Ruling R35)
|
// Oeffnungszeiten live: Karte 12, hinter den Terminplaner- und Webshop-Karten; der Schluessel hat 28 Zeichen (WOMS-Grenze 36, Ruling R35)
|
||||||
assert.equal(DOCUMENTS.length, 10, 'keine Analyse-Karten und keine weiteren')
|
assert.equal(DOCUMENTS.length, 12, 'keine Analyse-Karten und keine weiteren')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('zeiten_karte_vorhanden_am_ende', () => {
|
test('zeiten_karte_vorhanden_am_ende', () => {
|
||||||
@@ -73,10 +75,10 @@ test('zeiten_karte_vorhanden_am_ende', () => {
|
|||||||
key: 'leistungsbeschreibung-zeiten',
|
key: 'leistungsbeschreibung-zeiten',
|
||||||
title: 'Leistungsbeschreibung Öffnungszeiten live',
|
title: 'Leistungsbeschreibung Öffnungszeiten live',
|
||||||
liveUrl: 'https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html',
|
liveUrl: 'https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html',
|
||||||
sort: 10,
|
sort: 12,
|
||||||
})
|
})
|
||||||
assert.ok(d.key.length <= 36, 'Dokument-ID hoechstens 36 Zeichen')
|
assert.ok(d.key.length <= 36, 'Dokument-ID hoechstens 36 Zeichen')
|
||||||
assert.equal(DOCUMENTS.at(-1), d, 'am Ende von DOCUMENTS, nach den Terminplaner-Karten')
|
assert.equal(DOCUMENTS.at(-1), d, 'am Ende von DOCUMENTS, nach den Terminplaner- und Webshop-Karten')
|
||||||
assert.equal(DOCUMENTS.findIndex((x) => x.key === 'widerrufsbelehrung-funktionen'), 8, 'Terminplaner-Karten unveraendert davor')
|
assert.equal(DOCUMENTS.findIndex((x) => x.key === 'widerrufsbelehrung-funktionen'), 8, 'Terminplaner-Karten unveraendert davor')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -106,6 +108,8 @@ test('dry_run_listet_karten', () => {
|
|||||||
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext leistungsbeschreibung-terminplaner:')))
|
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext leistungsbeschreibung-terminplaner:')))
|
||||||
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext avv-terminplaner:')))
|
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext avv-terminplaner:')))
|
||||||
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext widerrufsbelehrung-funktionen:')))
|
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext widerrufsbelehrung-funktionen:')))
|
||||||
|
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext leistungsbeschreibung-webshop:')))
|
||||||
|
assert.ok(zeilen.some((z) => z.startsWith('Rechtstext avv-webshop:')))
|
||||||
assert.ok(zeilen.includes('Rechtstext leistungsbeschreibung-zeiten: Leistungsbeschreibung Öffnungszeiten live'))
|
assert.ok(zeilen.includes('Rechtstext leistungsbeschreibung-zeiten: Leistungsbeschreibung Öffnungszeiten live'))
|
||||||
assert.ok(!/angelegt|vorhanden|Fertig/.test(r.stdout), 'Trockenlauf legt nichts an')
|
assert.ok(!/angelegt|vorhanden|Fertig/.test(r.stdout), 'Trockenlauf legt nichts an')
|
||||||
})
|
})
|
||||||
@@ -20,12 +20,28 @@ const {
|
|||||||
textformNoetig,
|
textformNoetig,
|
||||||
termineTextformLabel,
|
termineTextformLabel,
|
||||||
termineChatTemplate,
|
termineChatTemplate,
|
||||||
|
shopTextformLabel,
|
||||||
|
shopChatTemplate,
|
||||||
|
TEXTFORM_FUNKTIONEN,
|
||||||
textformAnsicht,
|
textformAnsicht,
|
||||||
textformBereit,
|
textformBereit,
|
||||||
featureBody,
|
featureBody,
|
||||||
|
speicherStatus,
|
||||||
|
K16_WARNUNG,
|
||||||
|
warenartLabel,
|
||||||
|
artikelzahlLabel,
|
||||||
|
anfrageStatus,
|
||||||
|
anfrageZeilen,
|
||||||
|
anfrageText,
|
||||||
|
stripeText,
|
||||||
|
stripeTon,
|
||||||
|
shopHatEintrag,
|
||||||
|
begruendungOk,
|
||||||
|
entscheidungBody,
|
||||||
|
entscheidungStatus,
|
||||||
} = await loadModule('src/lib/funktionenView.js')
|
} = await loadModule('src/lib/funktionenView.js')
|
||||||
|
|
||||||
test('FUNKTIONEN: News, Terminplaner und Öffnungszeiten live (in dieser Reihenfolge) mit den Bestätigungstexten', () => {
|
test('FUNKTIONEN: News, Terminplaner, Webshop und Öffnungszeiten live (in dieser Reihenfolge) mit den Bestätigungstexten', () => {
|
||||||
assert.deepEqual(FUNKTIONEN, [
|
assert.deepEqual(FUNKTIONEN, [
|
||||||
{
|
{
|
||||||
key: 'news',
|
key: 'news',
|
||||||
@@ -45,6 +61,16 @@ test('FUNKTIONEN: News, Terminplaner und Öffnungszeiten live (in dieser Reihenf
|
|||||||
ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?',
|
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',
|
key: 'zeiten',
|
||||||
label: 'Öffnungszeiten live',
|
label: 'Öffnungszeiten live',
|
||||||
@@ -59,7 +85,7 @@ test('FUNKTIONEN: News, Terminplaner und Öffnungszeiten live (in dieser Reihenf
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('FUNKTIONEN: Schlüssel sind eindeutig und jede Funktion hat Aktivieren und Abschalten bestätigt', () => {
|
test('FUNKTIONEN: Schlüssel sind eindeutig und jede Funktion hat Aktivieren und Abschalten bestätigt', () => {
|
||||||
assert.deepEqual(FUNKTIONEN.map((f) => f.key), ['news', 'termine', 'zeiten'])
|
assert.deepEqual(FUNKTIONEN.map((f) => f.key), ['news', 'termine', 'shop', 'zeiten'])
|
||||||
for (const f of FUNKTIONEN) {
|
for (const f of FUNKTIONEN) {
|
||||||
assert.ok(f.bestaetigen.activate && f.bestaetigen.disable, f.key)
|
assert.ok(f.bestaetigen.activate && f.bestaetigen.disable, f.key)
|
||||||
}
|
}
|
||||||
@@ -434,6 +460,71 @@ test('textformNoetig: nur "Aktivieren (ohne Zahlung)" des Terminplaners und nur,
|
|||||||
assert.equal(textformNoetig(undefined, 'activate', termine()), false)
|
assert.equal(textformNoetig(undefined, 'activate', termine()), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Feld "shop" der Portal-Antwort GET /funktionen/:customerId (Fassung = config.shop.fassung)
|
||||||
|
const shopFeld = (extra = {}) => ({
|
||||||
|
textformNoetig: true,
|
||||||
|
avvVersion: '2026-10-05',
|
||||||
|
links: {
|
||||||
|
lb: 'https://project.webklar.com/rechtliches/leistungsbeschreibung-webshop-2026-10-05.html',
|
||||||
|
avv: 'https://project.webklar.com/rechtliches/avv-webshop-2026-10-05.html',
|
||||||
|
},
|
||||||
|
...extra,
|
||||||
|
})
|
||||||
|
|
||||||
|
test('textformNoetig: auch das Aktivieren (ohne Zahlung) des Webshops, mit dem Feld shop; die Funktion kommt aus dem Schlüssel', () => {
|
||||||
|
assert.deepEqual(TEXTFORM_FUNKTIONEN, ['termine', 'shop'])
|
||||||
|
assert.equal(textformNoetig('shop', 'activate', shopFeld()), true)
|
||||||
|
assert.equal(textformNoetig('shop', 'activate', shopFeld({ textformNoetig: false })), false)
|
||||||
|
assert.equal(textformNoetig('shop', 'activate', undefined), false, 'altes Portal ohne Feld shop: wie bisher')
|
||||||
|
assert.equal(textformNoetig('shop', 'activate', null), false)
|
||||||
|
assert.equal(textformNoetig('shop', 'activate', shopFeld({ textformNoetig: 'true' })), false, 'nur echtes true')
|
||||||
|
for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(textformNoetig('shop', action, shopFeld()), false, action)
|
||||||
|
assert.equal(textformNoetig('news', 'activate', shopFeld()), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shopTextformLabel und shopChatTemplate: wörtlich, mit Fassung, den Erklärungen des Nachweises und den versionierten Adressen', () => {
|
||||||
|
const l = shopTextformLabel('2026-10-05')
|
||||||
|
assert.equal(l.lb, 'Leistungsbeschreibung Webshop')
|
||||||
|
assert.equal(l.avv, 'Auftragsverarbeitungsvertrag Webshop')
|
||||||
|
assert.equal(
|
||||||
|
l.before + l.lb + l.between + l.avv + l.after,
|
||||||
|
'Der Kunde hat der Leistungsbeschreibung Webshop und dem Auftragsverarbeitungsvertrag Webshop (Fassung 2026-10-05) ' +
|
||||||
|
'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.',
|
||||||
|
)
|
||||||
|
const { lb, avv } = shopFeld().links
|
||||||
|
const text = shopChatTemplate('2026-10-05', { lb, avv })
|
||||||
|
assert.ok(text.startsWith('Damit wir den Webshop für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. '))
|
||||||
|
assert.ok(text.includes(`in der Fassung 2026-10-05: ${lb} · ${avv}. `))
|
||||||
|
assert.match(text, /leistungsbeschreibung-webshop-2026-10-05\.html/)
|
||||||
|
assert.match(text, /avv-webshop-2026-10-05\.html/)
|
||||||
|
assert.match(text, /Ich stimme zu/)
|
||||||
|
assert.match(text, /eigenes Stripe-Konto/)
|
||||||
|
assert.ok(!text.includes('undefined') && !text.includes('{') && !text.includes('Terminplaner'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('textformAnsicht(feld, "shop"): Label, Vorlage und Links des Webshops; ohne Schlüssel bleibt es der Terminplaner', () => {
|
||||||
|
const a = textformAnsicht(shopFeld(), 'shop')
|
||||||
|
assert.equal(a.version, '2026-10-05')
|
||||||
|
assert.deepEqual(a.links, shopFeld().links)
|
||||||
|
assert.deepEqual(a.label, shopTextformLabel('2026-10-05'))
|
||||||
|
assert.equal(a.vorlage, shopChatTemplate('2026-10-05', shopFeld().links))
|
||||||
|
assert.deepEqual(textformAnsicht(termine()).label, termineTextformLabel('2026-10-05'))
|
||||||
|
// Adressen ohne https werden nie Link, ohne beide Adressen keine Vorlage
|
||||||
|
const schlecht = textformAnsicht(shopFeld({ links: { lb: 'javascript:alert(1)', avv: 'http://x.example/a.html' } }), 'shop')
|
||||||
|
assert.deepEqual(schlecht.links, { lb: '', avv: '' })
|
||||||
|
assert.equal(schlecht.vorlage, '')
|
||||||
|
assert.equal(textformAnsicht(undefined, 'shop').version, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('textformBereit mit dem Feld shop: Datum zwischen Fassung und heute, ohne Fassung nie bereit', () => {
|
||||||
|
const ok = { confirmed: true, date: '2026-10-06', channel: 'email', today: '2026-10-07' }
|
||||||
|
assert.equal(textformBereit(ok, shopFeld()), true)
|
||||||
|
assert.equal(textformBereit({ ...ok, date: '2026-10-04' }, shopFeld()), false, 'vor der Fassung')
|
||||||
|
assert.equal(textformBereit(ok, shopFeld({ avvVersion: '' })), false)
|
||||||
|
assert.equal(textformBereit(ok, undefined), false)
|
||||||
|
})
|
||||||
|
|
||||||
test('termineChatTemplate: wörtlich, mit Fassung und den beiden versionierten Adressen', () => {
|
test('termineChatTemplate: wörtlich, mit Fassung und den beiden versionierten Adressen', () => {
|
||||||
const { lb, avv } = termine().links
|
const { lb, avv } = termine().links
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -524,3 +615,124 @@ test('featureBody: Aktivieren mit Textform trägt Datum und Weg, sonst bleibt de
|
|||||||
'halbe Angaben gehen nicht mit',
|
'halbe Angaben gehen nicht mit',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('speicherStatus: warnung true (K16 nicht gesendet) wird zur Warnung, sonst "Gespeichert."', () => {
|
||||||
|
assert.deepEqual(speicherStatus({ projects: [], warnung: true }), { ok: false, text: K16_WARNUNG })
|
||||||
|
assert.match(K16_WARNUNG, /K16/)
|
||||||
|
assert.match(K16_WARNUNG, /von Hand/)
|
||||||
|
assert.deepEqual(speicherStatus({ projects: [] }), { ok: true, text: 'Gespeichert.' })
|
||||||
|
assert.deepEqual(speicherStatus(undefined), { ok: true, text: 'Gespeichert.' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('aktionen: Webshop verhält sich wie der Terminplaner (ticketbasiert)', () => {
|
||||||
|
assert.deepEqual(aktionen('off', 'shop'), ['activate'])
|
||||||
|
assert.deepEqual(aktionen('disabled', 'shop'), ['activate'])
|
||||||
|
assert.deepEqual(aktionen('ordered', 'shop'), ['disable'])
|
||||||
|
assert.deepEqual(aktionen('active', 'shop'), ['disable'])
|
||||||
|
assert.deepEqual(aktionen('setup', 'shop'), ['abnahme', 'disable'])
|
||||||
|
assert.deepEqual(aktionen('setup', 'shop', { ticketMissing: true }), ['ticket', 'abnahme', 'disable'])
|
||||||
|
for (const state of ['off', 'ordered', 'active', 'disabled']) assert.deepEqual(aktionen(state, 'shop', { ticketMissing: true }), aktionen(state, 'shop'))
|
||||||
|
for (const action of aktionen('setup', 'shop', { ticketMissing: true })) assert.ok(FUNKTIONEN.find((f) => f.key === 'shop').bestaetigen[action], `Text für ${action}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('siteKeyOf: auch der Webshop zeigt seinen Site-Key', () => {
|
||||||
|
assert.equal(siteKeyOf({ state: 'active', siteKey: ' abcdefghijklmnop ' }, 'shop'), 'abcdefghijklmnop')
|
||||||
|
assert.equal(siteKeyOf({ state: 'active' }, 'shop'), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('warenartLabel und artikelzahlLabel: Beschriftungen, Unbekanntes bleibt sichtbar', () => {
|
||||||
|
assert.equal(warenartLabel('waren'), 'Waren')
|
||||||
|
assert.equal(warenartLabel('lebensmittel'), 'Lebensmittel')
|
||||||
|
assert.equal(warenartLabel('kleidung'), 'Kleidung')
|
||||||
|
assert.equal(warenartLabel('kunst'), 'Kunst/Handgemachtes')
|
||||||
|
assert.equal(warenartLabel('sonstiges'), 'Sonstiges')
|
||||||
|
assert.equal(warenartLabel('xyz'), 'xyz')
|
||||||
|
assert.equal(warenartLabel(undefined), '–')
|
||||||
|
assert.equal(warenartLabel('toString'), 'toString', 'kein Treffer über die Prototypkette')
|
||||||
|
assert.equal(artikelzahlLabel('bis20'), 'bis 20')
|
||||||
|
assert.equal(artikelzahlLabel('bis100'), 'bis 100')
|
||||||
|
assert.equal(artikelzahlLabel('mehr'), 'mehr als 100')
|
||||||
|
assert.equal(artikelzahlLabel(''), '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('anfrageStatus: Ton und Beschriftung', () => {
|
||||||
|
assert.deepEqual(anfrageStatus('offen'), ['warn', 'Offen'])
|
||||||
|
assert.deepEqual(anfrageStatus('angenommen'), ['ok', 'Angenommen'])
|
||||||
|
assert.deepEqual(anfrageStatus('abgelehnt'), ['danger', 'Abgelehnt'])
|
||||||
|
assert.deepEqual(anfrageStatus('zurueckgezogen'), ['muted', 'Zurückgezogen'])
|
||||||
|
assert.deepEqual(anfrageStatus(undefined), ['muted', '–'])
|
||||||
|
})
|
||||||
|
|
||||||
|
const ANFRAGE = { warenart: 'kunst', beschreibung: ' Handgemachte Kerzen ', artikelzahl: 'bis20', versand: true, abholung: false, unternehmer: true, keineAusgeschlossenen: true, nachricht: '' }
|
||||||
|
|
||||||
|
test('anfrageZeilen: alle Angaben der Anfrage als Zeilen', () => {
|
||||||
|
assert.deepEqual(anfrageZeilen(ANFRAGE), [
|
||||||
|
{ label: 'Warenart', text: 'Kunst/Handgemachtes' },
|
||||||
|
{ label: 'Beschreibung', text: 'Handgemachte Kerzen' },
|
||||||
|
{ label: 'Artikelzahl', text: 'bis 20' },
|
||||||
|
{ label: 'Übergabe', text: 'Versand' },
|
||||||
|
{ label: 'Unternehmer', text: 'Ja' },
|
||||||
|
{ label: 'Keine ausgeschlossenen Waren', text: 'Ja' },
|
||||||
|
{ label: 'Nachricht', text: '–' },
|
||||||
|
])
|
||||||
|
assert.equal(anfrageZeilen({ ...ANFRAGE, abholung: true }).find((z) => z.label === 'Übergabe').text, 'Versand und Abholung')
|
||||||
|
assert.equal(anfrageZeilen({ ...ANFRAGE, versand: false }).find((z) => z.label === 'Übergabe').text, '–')
|
||||||
|
assert.deepEqual(anfrageZeilen(null), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('anfrageText: eine Zeile aus Warenart, Menge und Übergabe', () => {
|
||||||
|
assert.equal(anfrageText(ANFRAGE), 'Kunst/Handgemachtes, bis 20 Artikel, Versand')
|
||||||
|
assert.equal(anfrageText({ ...ANFRAGE, abholung: true }), 'Kunst/Handgemachtes, bis 20 Artikel, Versand und Abholung')
|
||||||
|
assert.equal(anfrageText({ warenart: 'waren' }), 'Waren')
|
||||||
|
assert.equal(anfrageText(null), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stripeText: nicht verbunden, aktiv, Angaben fehlen', () => {
|
||||||
|
assert.equal(stripeText(null, false), 'nicht verbunden')
|
||||||
|
assert.equal(stripeText(undefined), 'nicht verbunden')
|
||||||
|
assert.equal(stripeText(null, true), 'Konto angelegt, Stand noch nicht geprüft')
|
||||||
|
assert.equal(stripeText({ charges_enabled: true, payouts_enabled: true }, true), 'Zahlungen aktiv')
|
||||||
|
assert.equal(stripeText({ charges_enabled: true, payouts_enabled: false }, true), 'Zahlungen aktiv, Auszahlungen noch nicht freigegeben')
|
||||||
|
assert.equal(stripeText({ charges_enabled: false, currently_due: ['external_account', 'individual.dob.day'], past_due: [] }, true), 'Angaben fehlen: external_account, individual.dob.day')
|
||||||
|
assert.equal(stripeText({ charges_enabled: false, past_due: ['a'], currently_due: ['a', 'b'] }, true), 'Angaben fehlen: a, b', 'doppelte Einträge nur einmal')
|
||||||
|
assert.equal(stripeText({ charges_enabled: false, currently_due: ['a', 'b', 'c', 'd', 'e', 'f'] }, true), 'Angaben fehlen: a, b, c, d und 2 weitere')
|
||||||
|
assert.equal(stripeText({ charges_enabled: false, currently_due: [], disabled_reason: 'requirements.pending_verification' }, true), 'Angaben fehlen: requirements.pending_verification')
|
||||||
|
assert.equal(stripeText({ charges_enabled: false, details_submitted: true }, true), 'Angaben fehlen: Prüfung durch Stripe läuft')
|
||||||
|
assert.equal(stripeText({ charges_enabled: false, details_submitted: false }, true), 'Angaben fehlen: Einrichtung nicht abgeschlossen')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stripeTon: ok, warn, muted', () => {
|
||||||
|
assert.equal(stripeTon({ charges_enabled: true }, true), 'ok')
|
||||||
|
assert.equal(stripeTon({ charges_enabled: false }, true), 'warn')
|
||||||
|
assert.equal(stripeTon(null, true), 'warn')
|
||||||
|
assert.equal(stripeTon(null, false), 'muted')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shopHatEintrag: Anfrage oder Zustand außer "off"', () => {
|
||||||
|
assert.equal(shopHatEintrag({ state: 'off', anfrage: null }), false)
|
||||||
|
assert.equal(shopHatEintrag({ state: 'off', anfrage: { status: 'offen' } }), true)
|
||||||
|
assert.equal(shopHatEintrag({ state: 'active', anfrage: null }), true)
|
||||||
|
assert.equal(shopHatEintrag({ state: 'disabled' }), true)
|
||||||
|
assert.equal(shopHatEintrag(null), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('begruendungOk: Pflicht, höchstens 1000 Zeichen', () => {
|
||||||
|
assert.equal(begruendungOk(''), false)
|
||||||
|
assert.equal(begruendungOk(' '), false)
|
||||||
|
assert.equal(begruendungOk(undefined), false)
|
||||||
|
assert.equal(begruendungOk('Passt nicht'), true)
|
||||||
|
assert.equal(begruendungOk('x'.repeat(1000)), true)
|
||||||
|
assert.equal(begruendungOk('x'.repeat(1001)), false)
|
||||||
|
assert.equal(begruendungOk(` ${'x'.repeat(1000)} `), true, 'ohne Rand gezählt')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('entscheidungBody: Begründung nur beim Ablehnen, getrimmt', () => {
|
||||||
|
assert.deepEqual(entscheidungBody({ projectId: 'p1', entscheidung: 'annehmen', begruendung: 'egal' }), { projectId: 'p1', entscheidung: 'annehmen' })
|
||||||
|
assert.deepEqual(entscheidungBody({ projectId: 'p1', entscheidung: 'ablehnen', begruendung: ' Zu groß ' }), { projectId: 'p1', entscheidung: 'ablehnen', begruendung: 'Zu groß' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('entscheidungStatus: Warnung, wenn die Mail an den Kunden fehlschlug', () => {
|
||||||
|
assert.deepEqual(entscheidungStatus({ ok: true }), { ok: true, text: 'Gespeichert.' })
|
||||||
|
assert.deepEqual(entscheidungStatus({ ok: true, mailFehler: true }), { ok: false, text: 'Entscheidung gespeichert, Mail an den Kunden fehlgeschlagen.' })
|
||||||
|
assert.deepEqual(entscheidungStatus(null), { ok: true, text: 'Gespeichert.' })
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const { throttleRuns } = await loadModule('src/lib/throttleRuns.js')
|
||||||
|
|
||||||
|
test('Repo-Import hoechstens einmal pro Minute, laufender Abgleich wird geteilt', async () => {
|
||||||
|
let runs = 0
|
||||||
|
let release
|
||||||
|
const throttled = throttleRuns(() => { runs += 1; return new Promise((r) => { release = r }) }, 60000)
|
||||||
|
const a = throttled()
|
||||||
|
const b = throttled()
|
||||||
|
assert.equal(a, b)
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
release({ created: 0 })
|
||||||
|
await a
|
||||||
|
assert.equal(runs, 1)
|
||||||
|
assert.equal(await throttled(), null)
|
||||||
|
assert.equal(runs, 1)
|
||||||
|
const later = throttled({ now: Date.now() + 61000 })
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
release({ created: 1 })
|
||||||
|
assert.deepEqual(await later, { created: 1 })
|
||||||
|
assert.equal(runs, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Fehler wird weitergereicht, danach darf sofort neu versucht werden', async () => {
|
||||||
|
let runs = 0
|
||||||
|
const throttled = throttleRuns(async () => { runs += 1; if (runs === 1) throw new Error('weg'); return { ok: true } }, 60000)
|
||||||
|
await assert.rejects(throttled(), /weg/)
|
||||||
|
assert.deepEqual(await throttled(), { ok: true })
|
||||||
|
assert.equal(runs, 2)
|
||||||
|
assert.equal(await throttled(), null)
|
||||||
|
})
|
||||||
@@ -2,7 +2,7 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { loadModule } from './load.mjs'
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
const { isOutsideOrg, needsPreviewButton, assignSummary, assignSequentially } = await loadModule(
|
const { isOutsideOrg, needsPreviewButton, assignSummary, assignSequentially, assignableProjects, ticketIdForCustomer } = await loadModule(
|
||||||
'src/lib/projectAssign.js'
|
'src/lib/projectAssign.js'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,9 +17,36 @@ test('isOutsideOrg', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('needsPreviewButton', () => {
|
test('needsPreviewButton', () => {
|
||||||
assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'deployed' }), false)
|
assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'deployed' }), false)
|
||||||
assert.equal(needsPreviewButton({ previewUrl: '', status: 'synced' }), true)
|
assert.equal(needsPreviewButton({ previewUrl: '', status: 'synced' }), true)
|
||||||
assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'pending' }), true)
|
// gerade angelegt, der Runner baut noch (status noch synced/pending): kein "Preview einschalten"
|
||||||
|
assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'synced' }), false)
|
||||||
|
assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'pending' }), false)
|
||||||
|
// archiviert: erst reaktivieren
|
||||||
|
assert.equal(needsPreviewButton({ archived: true, status: 'archived' }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('assignableProjects: Flag archived zaehlt, nicht status', () => {
|
||||||
|
const all = [
|
||||||
|
{ $id: 'a', repoFullName: 'WEBklar/a', status: 'archived', archived: false }, // reaktiviert, alter status
|
||||||
|
{ $id: 'b', repoFullName: 'JUSN/b', status: 'synced', archived: true }, // in Gitea archiviert
|
||||||
|
{ $id: 'c', repoFullName: 'WEBklar/c', customerId: 'k2' }, // anderer Kunde
|
||||||
|
{ $id: 'd', repoFullName: 'WEBklar/d', customerId: 'k1' },
|
||||||
|
{ $id: 'e', repoFullName: 'WEBklar/e', isTemplate: true },
|
||||||
|
{ $id: 'f', projectName: 'ohne Repo' },
|
||||||
|
{ $id: 'g', repoFullName: 'WEBklar/g' },
|
||||||
|
]
|
||||||
|
const ids = assignableProjects(all, { customerId: 'k1' }, new Set(['g'])).map((p) => p.$id)
|
||||||
|
assert.deepEqual(ids, ['a', 'd'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ticketIdForCustomer: altes Ticket nur beim gleichen Kunden', () => {
|
||||||
|
const p = { customerId: 'A', ticketId: 'tA' }
|
||||||
|
const wo = { tA: { customerId: 'A' }, tB: { customerId: 'B' } }
|
||||||
|
assert.equal(ticketIdForCustomer(p, 'A', wo), 'tA')
|
||||||
|
assert.equal(ticketIdForCustomer(p, 'B', wo), '')
|
||||||
|
assert.equal(ticketIdForCustomer({ customerId: 'A', ticketId: 'tB' }, 'B', wo), 'tB')
|
||||||
|
assert.equal(ticketIdForCustomer({ customerId: '', ticketId: '' }, 'B', wo), '')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('assignSummary', () => {
|
test('assignSummary', () => {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches } = await loadModule('src/lib/projectList.js')
|
||||||
|
|
||||||
|
const P = [
|
||||||
|
{ $id: 'a' },
|
||||||
|
{ $id: 'b', customerId: 'k' },
|
||||||
|
{ $id: 'c', archived: true },
|
||||||
|
{ $id: 'd', isTemplate: true },
|
||||||
|
{ $id: 'e', isTemplate: true, archived: true },
|
||||||
|
]
|
||||||
|
const ids = (list) => list.map((p) => p.$id)
|
||||||
|
|
||||||
|
test('Aktive Projekte ohne Archiv und Vorlagen', () => {
|
||||||
|
assert.deepEqual(ids(projectsInView(P, 'all')), ['a', 'b'])
|
||||||
|
assert.deepEqual(ids(projectsInView(P, 'unassigned')), ['a'])
|
||||||
|
assert.deepEqual(ids(projectsInView(P, 'assigned')), ['b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Alle inkl. Archiv: wirklich alles (Vorlagen nur fuer Admins)', () => {
|
||||||
|
assert.deepEqual(ids(projectsInView(P, 'everything')), ['a', 'b', 'c'])
|
||||||
|
assert.deepEqual(ids(projectsInView(P, 'everything', { isAdmin: true })), ['a', 'b', 'c', 'd', 'e'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Archiviert und Vorlagen', () => {
|
||||||
|
assert.deepEqual(ids(projectsInView(P, 'archived')), ['c'])
|
||||||
|
assert.deepEqual(ids(projectsInView(P, 'archived', { isAdmin: true })), ['c', 'e'])
|
||||||
|
assert.deepEqual(ids(projectsInView(P, 'templates')), ['d'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Zaehler passen zu den Ansichten', () => {
|
||||||
|
assert.deepEqual(projectCounts(P), { all: 2, unassigned: 1, assigned: 1, archived: 1, templates: 1, everything: 3 })
|
||||||
|
assert.deepEqual(projectCounts(P, { isAdmin: true }).everything, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 04.10.2026: SHK-vorlage zur Vorlage gemacht -> verschwand kommentarlos aus "Aktive Projekte"
|
||||||
|
test('viewShowing: bleibt in der aktuellen Ansicht, wenn das Projekt dort steht', () => {
|
||||||
|
assert.equal(viewShowing(P[0], 'all', { isAdmin: true }), 'all')
|
||||||
|
assert.equal(viewShowing(P[1], 'assigned'), 'assigned')
|
||||||
|
assert.equal(viewShowing(P[3], 'everything', { isAdmin: true }), 'everything')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('viewShowing: zur Vorlage gemacht -> Vorlagen, zurueck -> Aktive Projekte', () => {
|
||||||
|
assert.equal(viewShowing({ $id: 'x', isTemplate: true }, 'all', { isAdmin: true }), 'templates')
|
||||||
|
assert.equal(viewShowing({ $id: 'x', isTemplate: true }, 'unassigned', { isAdmin: true }), 'templates')
|
||||||
|
assert.equal(viewShowing({ $id: 'x' }, 'templates', { isAdmin: true }), 'all')
|
||||||
|
assert.equal(viewShowing({ $id: 'x', archived: true }, 'all'), 'archived')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('viewShowing: ohne Admin keine Vorlagen-Ansicht, dann bleibt die Ansicht', () => {
|
||||||
|
assert.equal(viewShowing({ $id: 'x', isTemplate: true }, 'all'), 'all')
|
||||||
|
assert.equal(viewShowing(null, 'assigned'), 'assigned')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('matchesSearch: Titel, Subdomain, Repo; leer passt immer', () => {
|
||||||
|
const p = { projectName: 'SHK-vorlage', subdomain: 'shk-vorlage', repoFullName: 'knso/SHK-vorlage', giteaRepoName: 'SHK-vorlage' }
|
||||||
|
assert.equal(matchesSearch(p, ''), true)
|
||||||
|
assert.equal(matchesSearch(p, ' shk '), true)
|
||||||
|
assert.equal(matchesSearch(p, 'knso/'), true)
|
||||||
|
assert.equal(matchesSearch(p, 'olzinger'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hiddenMatches: Treffer, die die aktuelle Ansicht ausblendet', () => {
|
||||||
|
const list = [
|
||||||
|
{ $id: 'a', projectName: 'SHK Kunde' },
|
||||||
|
{ $id: 't', projectName: 'SHK-vorlage', isTemplate: true },
|
||||||
|
{ $id: 'r', projectName: 'SHK alt', archived: true },
|
||||||
|
{ $id: 'o', projectName: 'Olzinger' },
|
||||||
|
]
|
||||||
|
const shk = (p) => matchesSearch(p, 'shk')
|
||||||
|
assert.deepEqual(hiddenMatches(list, 'all', shk, { isAdmin: true }), [{ view: 'templates', count: 1 }, { view: 'archived', count: 1 }])
|
||||||
|
assert.deepEqual(hiddenMatches(list, 'all', shk), [{ view: 'archived', count: 1 }], 'Vorlagen nur fuer Admins')
|
||||||
|
assert.deepEqual(hiddenMatches(list, 'templates', shk, { isAdmin: true }), [{ view: 'archived', count: 1 }])
|
||||||
|
assert.deepEqual(hiddenMatches(list, 'everything', shk, { isAdmin: true }), [], 'zeigt schon alles')
|
||||||
|
assert.deepEqual(hiddenMatches(list, 'all', (p) => matchesSearch(p, 'olz'), { isAdmin: true }), [])
|
||||||
|
})
|
||||||
@@ -2,7 +2,14 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { loadModule } from './load.mjs'
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
const { suggestSubdomain } = await loadModule('src/lib/subdomain.js')
|
const { suggestSubdomain, slugify } = await loadModule('src/lib/subdomain.js')
|
||||||
|
|
||||||
|
test('slugify schreibt Umlaute aus wie das Portal', () => {
|
||||||
|
assert.equal(slugify('Müller & Söhne'), 'mueller-soehne')
|
||||||
|
assert.equal(slugify(' ZZ Test Ablauf '), 'zz-test-ablauf')
|
||||||
|
assert.equal(slugify('x'.repeat(70)), 'x'.repeat(63))
|
||||||
|
assert.equal(slugify(null), '')
|
||||||
|
})
|
||||||
|
|
||||||
test('Kundenname vor Projektname', () => {
|
test('Kundenname vor Projektname', () => {
|
||||||
assert.equal(suggestSubdomain('Döner Nation', 'D-ner-Nation'), 'doener-nation')
|
assert.equal(suggestSubdomain('Döner Nation', 'D-ner-Nation'), 'doener-nation')
|
||||||
|
|||||||
Reference in new issue
Block a user