Analyse: Schalter im WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
447b7e733e
commit
0cec45d3da
8 files changed
+211
-1
No files matched your search
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { fetchAnalyse, setAnalyse } from '../lib/analyseApi'
|
||||||
|
import { analyseStatus, avvLabel } from '../lib/analyseView'
|
||||||
|
|
||||||
|
const COLORS = { green: '#10b981', red: '#ef4444', grey: '#a0aec0' }
|
||||||
|
|
||||||
|
// Kompakte Zeile "Website-Analyse" je Projekt (lädt beim Einbinden; mit key=Projekt-ID verwenden)
|
||||||
|
export default function AnalyseRow({ projectId }) {
|
||||||
|
const [data, setData] = useState(null)
|
||||||
|
const [loadError, setLoadError] = useState('')
|
||||||
|
const [mode, setMode] = useState(null) // null | 'on' | 'off'
|
||||||
|
const [confirmed, setConfirmed] = useState(false)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetchAnalyse(projectId)
|
||||||
|
.then((r) => { if (!cancelled) setData(r) })
|
||||||
|
.catch((err) => { if (!cancelled) setLoadError(err.message) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
const apply = async (on) => {
|
||||||
|
setBusy(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await setAnalyse(projectId, { on, avvConfirmedVerbally: on })
|
||||||
|
setData(await fetchAnalyse(projectId))
|
||||||
|
setMode(null)
|
||||||
|
setConfirmed(false)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = () => { setMode(null); setConfirmed(false); setError('') }
|
||||||
|
const status = analyseStatus(data)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 10, fontSize: 13 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<span className="text-grey">Website-Analyse:</span>
|
||||||
|
{loadError ? (
|
||||||
|
<span style={{ color: COLORS.red }}>{loadError}</span>
|
||||||
|
) : data ? (
|
||||||
|
<span style={{ color: COLORS[status.color] }}>{status.text}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-grey">Lädt …</span>
|
||||||
|
)}
|
||||||
|
{status.canEnable && !mode && (
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setMode('on')}>Einschalten</button>
|
||||||
|
)}
|
||||||
|
{status.canDisable && !mode && (
|
||||||
|
<button type="button" className="btn btn-sm" onClick={() => setMode('off')}>Ausschalten</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'on' && data && (
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<label style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
|
||||||
|
{avvLabel(data.avvVersion)}
|
||||||
|
</label>
|
||||||
|
<button type="button" className="btn btn-sm" disabled={!confirmed || busy} onClick={() => apply(true)}>Einschalten</button>
|
||||||
|
<button type="button" className="btn btn-sm" disabled={busy} onClick={cancel}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'off' && (
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<span>Website-Analyse wirklich ausschalten?</span>
|
||||||
|
<button type="button" className="btn btn-sm" disabled={busy} onClick={() => apply(false)}>Ja, ausschalten</button>
|
||||||
|
<button type="button" className="btn btn-sm" disabled={busy} onClick={cancel}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div style={{ marginTop: 6, color: COLORS.red }}>{error}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { FaCodeBranch } from 'react-icons/fa'
|
import { FaCodeBranch } from 'react-icons/fa'
|
||||||
import { FaLinkSlash } from 'react-icons/fa6'
|
import { FaLinkSlash } from 'react-icons/fa6'
|
||||||
import PreviewBlock from './PreviewBlock'
|
import PreviewBlock from './PreviewBlock'
|
||||||
|
import AnalyseRow from './AnalyseRow'
|
||||||
import GiteaLinkButton from './GiteaLinkButton'
|
import GiteaLinkButton from './GiteaLinkButton'
|
||||||
import PreviewLinkButton from './PreviewLinkButton'
|
import PreviewLinkButton from './PreviewLinkButton'
|
||||||
import { suggestSubdomain } from '../lib/subdomain'
|
import { suggestSubdomain } from '../lib/subdomain'
|
||||||
@@ -112,6 +113,8 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
|||||||
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
|
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{project.subdomain && <AnalyseRow key={project.$id} projectId={project.$id} />}
|
||||||
|
|
||||||
{showActions && project.subdomain && (
|
{showActions && project.subdomain && (
|
||||||
<div style={{ marginTop: 10 }}>
|
<div style={{ marginTop: 10 }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { account } from './appwrite'
|
||||||
|
|
||||||
|
const PROJECT_ADMIN_URL =
|
||||||
|
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
|
||||||
|
|
||||||
|
// Wie previewFetch in previewApi.js: der Fehler trägt .status und .data
|
||||||
|
async function analyseFetch(path, options = {}) {
|
||||||
|
const jwt = await account.createJWT()
|
||||||
|
const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt.jwt}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(data.error || `API-Fehler ${response.status}`)
|
||||||
|
error.status = response.status
|
||||||
|
error.data = data
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = (projectId) => `/api/admin/analyse/${encodeURIComponent(projectId)}`
|
||||||
|
|
||||||
|
export function fetchAnalyse(projectId) {
|
||||||
|
return analyseFetch(base(projectId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAnalyse(projectId, { on, avvConfirmedVerbally = false }) {
|
||||||
|
return analyseFetch(base(projectId), { method: 'POST', body: JSON.stringify({ on, avvConfirmedVerbally }) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Anzeige-Logik für die Zeile "Website-Analyse" (Schalter im WOMS)
|
||||||
|
|
||||||
|
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
timeZone: 'Europe/Berlin',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
// "TT.MM.JJJJ" in Europe/Berlin
|
||||||
|
function formatBerlinDate(value) {
|
||||||
|
const d = new Date(value)
|
||||||
|
if (!value || Number.isNaN(d.getTime())) return ''
|
||||||
|
const parts = Object.fromEntries(dateFormat.formatToParts(d).map((p) => [p.type, p.value]))
|
||||||
|
return `${parts.day}.${parts.month}.${parts.year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Antwort von GET /api/admin/analyse/:projectId => Statustext und erlaubte Aktionen
|
||||||
|
export function analyseStatus(a) {
|
||||||
|
if (!a) return { text: '', color: 'grey', canEnable: false, canDisable: false }
|
||||||
|
if (!a.enabled) return { text: 'Noch nicht freigeschaltet', color: 'grey', canEnable: false, canDisable: false }
|
||||||
|
if (a.state === 'active') {
|
||||||
|
const at = formatBerlinDate(a.activatedAt)
|
||||||
|
return { text: at ? `An seit ${at}` : 'An', color: 'green', canEnable: false, canDisable: true }
|
||||||
|
}
|
||||||
|
if (a.state === 'disabled') return { text: 'Gesperrt', color: 'red', canEnable: Boolean(a.eligible), canDisable: false }
|
||||||
|
if (!a.eligible) {
|
||||||
|
return { text: `Nicht verfügbar${a.reasonText ? `: ${a.reasonText}` : ''}`, color: 'grey', canEnable: false, canDisable: false }
|
||||||
|
}
|
||||||
|
return { text: 'Aus', color: 'grey', canEnable: true, canDisable: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const avvLabel = (version) =>
|
||||||
|
`Der Kunde hat den Auftragsverarbeitungsvertrag (Fassung ${version}) bestätigt`
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Aufbereitung der /api/admin/services-Antwort für die Seite "Services"
|
// Aufbereitung der /api/admin/services-Antwort für die Seite "Services"
|
||||||
const STATUS_LABELS = { in_development: 'In Entwicklung' }
|
const STATUS_LABELS = { in_development: 'In Entwicklung', available: 'Verfügbar' }
|
||||||
|
|
||||||
export function servicesCards(api) {
|
export function servicesCards(api) {
|
||||||
if (!api) return []
|
if (!api) return []
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export default function ServicesPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{p.projectName || p.repoFullName}
|
{p.projectName || p.repoFullName}
|
||||||
|
{p.features?.analyse === 'active' && <Badge>Analyse an</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<div className="muted">
|
<div className="muted">
|
||||||
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
|
{[p.customerName, p.previewUrl && 'Preview'].filter(Boolean).join(' · ') || 'Intern'}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const { analyseStatus, avvLabel } = await loadModule('src/lib/analyseView.js')
|
||||||
|
|
||||||
|
test('analyseStatus: Schalter aus => noch nicht freigeschaltet, kein Button', () => {
|
||||||
|
const s = analyseStatus({ enabled: false })
|
||||||
|
assert.equal(s.text, 'Noch nicht freigeschaltet')
|
||||||
|
assert.equal(s.canEnable, false)
|
||||||
|
assert.equal(s.canDisable, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('analyseStatus: an seit Datum (Europe/Berlin)', () => {
|
||||||
|
const s = analyseStatus({ enabled: true, state: 'active', eligible: true, activatedAt: '2026-10-01T22:30:00Z' })
|
||||||
|
assert.equal(s.text, 'An seit 02.10.2026')
|
||||||
|
assert.equal(s.canDisable, true)
|
||||||
|
assert.equal(s.canEnable, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('analyseStatus: an ohne Datum bleibt lesbar', () => {
|
||||||
|
assert.equal(analyseStatus({ enabled: true, state: 'active', eligible: true }).text, 'An')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('analyseStatus: aus und berechtigt => Einschalten moeglich', () => {
|
||||||
|
const s = analyseStatus({ enabled: true, state: 'off', eligible: true })
|
||||||
|
assert.equal(s.text, 'Aus')
|
||||||
|
assert.equal(s.canEnable, true)
|
||||||
|
assert.equal(analyseStatus({ enabled: true, state: null, eligible: true }).text, 'Aus')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('analyseStatus: gesperrt', () => {
|
||||||
|
const s = analyseStatus({ enabled: true, state: 'disabled', eligible: true })
|
||||||
|
assert.equal(s.text, 'Gesperrt')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('analyseStatus: nicht berechtigt => Grund, kein Button', () => {
|
||||||
|
const s = analyseStatus({ enabled: true, state: null, eligible: false, reasonText: 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.' })
|
||||||
|
assert.equal(s.text, 'Nicht verfügbar: Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.')
|
||||||
|
assert.equal(s.canEnable, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('analyseStatus: robust bei leerer Antwort', () => {
|
||||||
|
assert.equal(analyseStatus(null).text, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('avvLabel nennt die Fassung', () => {
|
||||||
|
assert.equal(avvLabel('2026-10-01'), 'Der Kunde hat den Auftragsverarbeitungsvertrag (Fassung 2026-10-01) bestätigt')
|
||||||
|
})
|
||||||
@@ -38,3 +38,8 @@ test('servicesCards: robust bei leerer Antwort und unbekannter Funktion', () =>
|
|||||||
const cards = servicesCards({ kinds: [{ key: 'x', label: 'X', features: ['foo'] }] })
|
const cards = servicesCards({ kinds: [{ key: 'x', label: 'X', features: ['foo'] }] })
|
||||||
assert.deepEqual(cards[0].features, [{ label: 'foo', statusLabel: '' }])
|
assert.deepEqual(cards[0].features, [{ label: 'foo', statusLabel: '' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('servicesCards: Status "available" => "Verfügbar"', () => {
|
||||||
|
const cards = servicesCards({ kinds, features: { analyse: { label: 'Website-Analyse', status: 'available' } }, groups: {} })
|
||||||
|
assert.deepEqual(cards[0].features, [{ label: 'Website-Analyse', statusLabel: 'Verfügbar' }])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user