diff --git a/scripts/setup-legal.mjs b/scripts/setup-legal.mjs index 6b9de37..79e1e31 100644 --- a/scripts/setup-legal.mjs +++ b/scripts/setup-legal.mjs @@ -99,6 +99,9 @@ export const DOCUMENTS = [ { 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: "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 { key: "leistungsbeschreibung-zeiten", title: "Leistungsbeschreibung Öffnungszeiten live", liveUrl: "https://project.webklar.com/vertragsunterlagen/zeiten-leistungsbeschreibung.html" }, ].map((d, i) => ({ ...d, sort: i + 1 })); diff --git a/src/components/TicketProjects.jsx b/src/components/TicketProjects.jsx index 7d94978..e1c395e 100644 --- a/src/components/TicketProjects.jsx +++ b/src/components/TicketProjects.jsx @@ -1,28 +1,13 @@ import { useState, useEffect, useCallback } from 'react' import { FaPlus, FaLink } from 'react-icons/fa6' import { useWebsiteProjects } from '../hooks/useWebsiteProjects' -import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi' -import { isOutsideOrg, assignSummary } from '../lib/projectAssign' +import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi' +import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign' +import { slugify } from '../lib/subdomain' import AssignedProjectCard from './AssignedProjectCard' import { fetchServices, completeAssignment } from '../lib/projectKindsApi' 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. // Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt. export default function TicketProjects({ ticket }) { @@ -57,7 +42,7 @@ export default function TicketProjects({ ticket }) { setImporting(true) setImportHint('') try { - await importNewRepos() + await importNewReposThrottled() } catch (err) { if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`) } finally { @@ -73,10 +58,11 @@ export default function TicketProjects({ ticket }) { setError('') try { await action() - await load() } catch (err) { setError(err.message || 'Aktion fehlgeschlagen') } finally { + // Auch nach einem Fehler neu laden: das Repo kann trotzdem entstanden sein + try { await load() } catch { /* Anzeige bleibt wie sie ist */ } setBusy(false) } } @@ -149,6 +135,10 @@ export default function TicketProjects({ ticket }) { const create = (e) => { e.preventDefault() + if (!slugify(createForm.subdomain)) { + setError('Bitte eine gültige Subdomain eingeben (Buchstaben, Ziffern, Bindestrich).') + return + } run(async () => { const subdomain = slugify(createForm.subdomain) await createProjectFromTemplate({ @@ -157,6 +147,8 @@ export default function TicketProjects({ ticket }) { subdomain, customerId: ticket.customerId || '', ticketId: ticket.$id, + // Aus der Website-Vorlage mit Subdomain: die Art steht fest (sonst "Art fehlt") + kind: 'website', }) setCreateForm({ displayName: '', subdomain: '' }) setShowCreate(false) diff --git a/src/components/customer/CustomerFunktionen.jsx b/src/components/customer/CustomerFunktionen.jsx index 2e39052..5387244 100644 --- a/src/components/customer/CustomerFunktionen.jsx +++ b/src/components/customer/CustomerFunktionen.jsx @@ -3,18 +3,19 @@ import { Link } from 'react-router-dom' import { FaCheck, FaCopy, FaRotateRight, FaSpinner } from 'react-icons/fa6' import { Card, Badge, Button, EmptyState } from '../ui' import { copyText } from '../CopyableCredential' -import { getFunktionen, setFeature, getTermineAnleitung } from '../../lib/teamApi' +import { getFunktionen, setFeature, getTermineAnleitung, getShop, entscheideShopAnfrage } from '../../lib/teamApi' import { 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' 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 ZeitenPanel from './ZeitenPanel' 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) // 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). -function TextformPanel({ termine, value, onChange, projectId, today, disabled }) { - const ansicht = textformAnsicht(termine) +function TextformPanel({ funktion, feld, value, onChange, projectId, today, disabled }) { + const ansicht = textformAnsicht(feld, funktion) const { label, links, vorlage } = ansicht return (
@@ -141,7 +142,7 @@ function TextformPanel({ termine, value, onChange, projectId, today, disabled }) Weg: {CHANNELS.map((c) => ( ))} @@ -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 ( +
+
+ Webshop: {shop.projectName} + {a && Anfrage: {label}} + {shop.geoeffnet ? 'Geöffnet' : 'Geschlossen'} + Stripe: {stripeText(stripe.status, !!stripe.accountId)} +
+ {a && ( +
+ {anfrageZeilen(a.anfrage).map((z) => ( +
{z.label}
{z.text}
+ ))} +
Eingegangen
{a.at ? fmtDateTime(a.at) : '–'}
+ {a.entschiedenAt && <>
Entschieden
{fmtDateTime(a.entschiedenAt)}
} + {a.begruendung && <>
Begründung
{a.begruendung}
} +
+ )} +
Artikel: {shop.zahlen?.artikel ?? 0} · Offene Bestellungen: {shop.zahlen?.offeneBestellungen ?? 0}
+ {a?.status === 'offen' && ( + frage ? ( +
+ {frage === 'annehmen' ? 'Anfrage annehmen? Der Kunde bekommt eine Mail.' : 'Anfrage ablehnen? Der Kunde bekommt die Begründung per Mail.'} + {frage === 'ablehnen' && ( +