Kundenansicht neu P2: Ansicht Website mit Projektdetails als Etage

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 16:55:16 +00:00
1 parent e036d24ae5
commit a3831040ba
4 files changed
+208 -11

No files matched your search

+130 -4
View File
@@ -1,7 +1,133 @@
/* Kundenansicht neu: Platzhalter, den das nächste Paket füllt (Stub aus P1). */
/* Kundenansicht neu (P2): Bereich Website. Projektliste mit Vorschau, offene Rechnung, Installationshinweis und Aktivität;
als Etage "projekt" die Projektdetails (#projekt?projekt=<id>). Gezeichnet wird mit h() und Textknoten. */
import { h } from './dom.js'
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, zuAnsicht, meldeTitel, fmtDatumZeit, safeUrl } from './ansicht-hilfen.js'
import { baueAktivitaet, baueProjektDetails, ladeProjektDetails } from './website-details.js'
import { ladeOffeneRechnungen, rechnungOffenZeile } from './rechnung-offen.js'
import { starteInstallQuelle, installAngebot, baueInstallHinweis, istAusgeblendet, merkeAusgeblendet } from './install-hinweis.js'
import { HANDY_ABFRAGE } from './leiste.js'
export async function zeigeWebsite(wurzel, { route, me, nav }) {
wurzel.replaceChildren(h('p', { class: 'kn-leer' }, 'Website (folgt)'))
const VORSCHAU_BASISBREITE = 1280
const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs
/** Vorschau fertig: Status ready oder deployed und eine Adresse (wie app.js isProjectPreviewReady). */
export function vorschauBereit(project) {
const status = String(project?.provisioningStatus || project?.status || '').toLowerCase()
return ['ready', 'deployed'].includes(status) && Boolean(project?.previewUrl)
}
/** Ton und Text der Status-Pille eines Projekts. */
export function projektStatus(project) {
if (vorschauBereit(project)) return { ton: 'gruen', text: 'Vorschau online' }
const status = String(project?.provisioningStatus || project?.status || '').toLowerCase()
if (status.includes('fail')) return { ton: 'rot', text: 'Vorschau gestört' }
if (project?.previewUrl) return { ton: 'orange', text: 'Vorschau wird vorbereitet' }
return { ton: 'grau', text: 'Noch keine Website' }
}
// Der Frame zeigt die Seite in 1280 px Breite und wird auf die Breite des Rahmens verkleinert
function skaliereVorschau(rahmen) {
const frame = rahmen.querySelector('iframe')
if (!frame) return
const anwenden = () => {
const breite = rahmen.clientWidth
if (breite && frame.style) frame.style.transform = `scale(${breite / VORSCHAU_BASISBREITE})`
}
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(anwenden).observe(rahmen)
anwenden()
}
function hostVon(url) {
try { return new URL(url).hostname || '' } catch { return '' }
}
const fakt = (titel, wert) => h('div', null, h('dt', null, titel), h('dd', null, wert))
function projektZeile(projekt) {
const bereit = vorschauBereit(projekt)
const url = safeUrl(projekt.previewUrl)
const status = projektStatus(projekt)
const rahmen = bereit && url
? h('div', { class: 'kn-vorschau', 'data-vorschau': true }, h('iframe', { src: url, loading: 'lazy', scrolling: 'no', tabindex: '-1', 'aria-hidden': 'true', title: 'Vorschau deiner Website' }))
: h('div', { class: 'kn-vorschau kn-vorschau-leer' }, projekt.previewUrl ? 'Vorschau wird vorbereitet …' : 'Noch keine Website veröffentlicht')
if (bereit && url) skaliereVorschau(rahmen)
const host = hostVon(url)
return h('li', { class: 'kn-projekt', 'data-projekt': projekt.id },
rahmen,
h('div', { class: 'kn-projekt-koerper' },
h('div', { class: 'kn-projekt-kopf' }, h('h2', { class: 'kn-projekt-name' }, projekt.projectName || projekt.subdomain || 'Projekt'), h('span', { class: K.pille[status.ton] }, status.text)),
h('dl', { class: 'kn-fakten' },
fakt('Vorschau-Adresse', host || '–'),
fakt('Live-Domain', projekt.liveDomain || 'noch nicht verbunden'),
fakt('Letzte Änderung', projekt.updatedAt ? fmtDatumZeit(projekt.updatedAt) : '–')),
h('div', { class: 'kn-projekt-links' },
bereit && url ? h('a', { class: K.knopf, href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Website öffnen') : null,
h('a', { class: K.knopf, ...zuAnsicht('projekt', projekt.id) }, 'Projektdetails'))))
}
// Installationshinweis: ersetzt sich selbst, wenn sich Installierbarkeit oder Auswahl ändern
function installPlatz() {
const platz = h('div', { class: 'kn-install-platz' })
const win = window
const quelle = starteInstallQuelle(win)
const handy = () => (typeof win.matchMedia === 'function' ? Boolean(win.matchMedia(HANDY_ABFRAGE)?.matches) : false)
const zeichne = () => {
const angebot = installAngebot({ appLayout: handy(), standalone: quelle.standalone(), canPrompt: quelle.kannPrompt(), ios: quelle.ios, dismissed: istAusgeblendet() })
leeren(platz)
if (angebot.zeigen) platz.append(baueInstallHinweis({ art: angebot.art, onInstall: () => quelle.installiere(), onDismiss: () => { merkeAusgeblendet(); zeichne() } }))
}
zeichne()
aktualisierer.set(quelle, zeichne)
if (!angemeldet.has(quelle)) { angemeldet.add(quelle); quelle.beiAenderung(() => aktualisierer.get(quelle)?.()) }
return platz
}
const aktualisierer = new WeakMap()
const angemeldet = new WeakSet()
function aktivitaetAbschnitt(antwort, jetzt) {
const inhalt = antwort.fehler
? h('p', { class: 'kn-hinweis' }, `Aktivität konnte nicht geladen werden: ${antwort.fehler}`)
: baueAktivitaet(antwort.tickets, jetzt)
return h('section', { class: 'kn-sektion', 'data-abschnitt': 'aktivitaet' }, h('h2', { class: K.abschnitt }, 'Was wir zuletzt erledigt haben'), inhalt)
}
const projekteLaden = () => api('/api/projects').then((a) => ({ liste: Array.isArray(a?.projects) ? a.projects : [] }), (f) => ({ fehler: f.message }))
const aktivitaetLaden = () => api('/api/activity').then((a) => ({ tickets: Array.isArray(a?.tickets) ? a.tickets : [] }), (f) => ({ fehler: f.message }))
const projektId = (query) => { const p = new URLSearchParams(query || ''); return p.get('projekt') || p.get('id') || '' }
export async function zeigeWebsite(wurzel, { route, me, nav } = {}) {
const nr = (laeufe.get(wurzel) ?? 0) + 1
laeufe.set(wurzel, nr)
const aktuell = () => laeufe.get(wurzel) === nr
leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …'))
const jetzt = new Date()
if (route?.etage === 'projekt') {
const projekte = await projekteLaden()
if (!aktuell()) return
if (projekte.fehler) { leeren(wurzel).append(h('p', { class: K.fehler }, `Projekte konnten nicht geladen werden: ${projekte.fehler}`)); return }
const wunsch = projektId(route.query)
const projekt = wunsch ? projekte.liste.find((p) => p.id === wunsch) : projekte.liste[0]
if (!projekt) { leeren(wurzel).append(h('p', { class: K.leer }, projekte.liste.length ? 'Dieses Projekt wurde nicht gefunden.' : 'Noch keine Website-Projekte zugewiesen.')); return }
meldeTitel(wurzel, projekt.projectName || projekt.subdomain || 'Projekt')
const details = await ladeProjektDetails(projekt.id)
if (!aktuell()) return
leeren(wurzel).append(baueProjektDetails(details, jetzt))
return
}
const [projekte, aktivitaet, offen] = await Promise.all([projekteLaden(), aktivitaetLaden(), ladeOffeneRechnungen()])
if (!aktuell()) return
const zeile = rechnungOffenZeile(offen)
const liste = projekte.fehler
? h('p', { class: K.fehler }, `Projekte konnten nicht geladen werden: ${projekte.fehler}`)
: projekte.liste.length
? h('ul', { class: 'kn-projekte' }, projekte.liste.map(projektZeile))
: h('p', { class: K.leer }, 'Noch keine Website-Projekte zugewiesen.')
const aktivitaetSektion = aktivitaetAbschnitt(aktivitaet, jetzt)
leeren(wurzel).append(zeile, installPlatz(), liste, aktivitaetSektion)
if (route?.scroll === 'aktivitaet') aktivitaetSektion.scrollIntoView({ block: 'start' })
}
+4 -5
View File
@@ -169,10 +169,9 @@ export function baueProjektDetails({ git, files, work }, jetzt = new Date()) {
return h('div', { class: 'kn-details' },
order ? h('section', { class: 'kn-sektion' }, baueAuftrag(order, work?.totalMinutes || 0)) : null,
abschnitt('Letzte Änderungen', git ? baueAenderungen(git.changes || [], jetzt) : leerText('Die Änderungen können gerade nicht geladen werden.')),
order || !work
? abschnitt('Arbeiten am Projekt', work
? baueArbeiten(work.entries || [], 'Noch keine Arbeitszeit eingetragen. Was sich an der Website geändert hat, steht unter „Letzte Änderungen“.')
: leerText('Die Arbeiten können gerade nicht geladen werden.'))
: null,
// Der Abschnitt steht immer da (Test Etage projekt): ohne Auftrag zeigt er den Leertext, bei Ausfall den Fehlertext
abschnitt('Arbeiten am Projekt', work
? baueArbeiten(work.entries || [], 'Noch keine Arbeitszeit eingetragen. Was sich an der Website geändert hat, steht unter „Letzte Änderungen“.')
: leerText('Die Arbeiten können gerade nicht geladen werden.')),
abschnitt('Dateien', files ? baueDateien(files) : leerText('Die Datei-Übersicht kann gerade nicht geladen werden.')))
}
+72
View File
@@ -0,0 +1,72 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { zeigeWebsite, projektStatus, vorschauBereit } from '../public/neu/ansicht-website.js'
import { mit, me, projekt, rechnung, neueWurzel } from './support/knDaten.js'
const HAUPT = { 'GET /api/projects': { projects: [projekt({ id: 'p1', projectName: 'Bäckerei <b>Roth</b>' })] }, 'GET /api/activity': { tickets: [] }, 'GET /api/invoices': { invoices: [] } }
const zeige = async (fx, route = { bereich: 'website', query: '' }, m = me()) => { const w = neueWurzel(fx); await zeigeWebsite(w, { route, me: m, nav: 'tab' }); await fx.ruhe(); return w }
test('projektStatus', () => {
const s = (o) => projektStatus(projekt(o))
assert.deepEqual(s({}), { ton: 'gruen', text: 'Vorschau online' })
assert.deepEqual(s({ status: 'building' }), { ton: 'orange', text: 'Vorschau wird vorbereitet' })
assert.deepEqual(s({ provisioningStatus: 'failed' }), { ton: 'rot', text: 'Vorschau gestört' })
assert.deepEqual(s({ previewUrl: '' }), { ton: 'grau', text: 'Noch keine Website' })
assert.equal(vorschauBereit(projekt({ status: 'deployed' })), true); assert.equal(vorschauBereit(projekt({ status: 'building' })), false)
})
test('Website: Ladezustand ist Text und verschwindet', mit(HAUPT, async (fx) => {
const w = neueWurzel(fx); const lauf = zeigeWebsite(w, { route: { bereich: 'website', query: '' }, me: me(), nav: 'tab' })
assert.match(fx.text(w), /Wird geladen …/); await lauf; await fx.ruhe(); assert.doesNotMatch(fx.text(w), /Wird geladen …/)
}))
test('Website: Zeile je Projekt mit Frame, Status, Link über die Brücke; Name bleibt Text', mit(HAUPT, async (fx) => {
const w = await zeige(fx)
assert.equal(fx.finde(w, 'iframe').getAttribute('src'), 'https://baeckerei.project.webklar.com')
assert.match(fx.text(w), /Vorschau online/); assert.match(fx.text(w), /Bäckerei <b>Roth<\/b>/); assert.equal(fx.finde(w, 'b'), null)
const a = fx.finde(w, '[data-projekt="p1"] a[data-view="projekt"]'); assert.deepEqual([a.textContent, a.getAttribute('href'), a.getAttribute('data-project')], ['Projektdetails', '#projekt?projekt=p1', 'p1'])
}))
test('Website: unsichere Vorschau-Adresse ergibt weder Frame noch Link', mit({ ...HAUPT, 'GET /api/projects': { projects: [projekt({ previewUrl: 'javascript:alert(1)' })] } }, async (fx) => {
const w = await zeige(fx)
assert.equal(fx.finde(w, 'iframe'), null); assert.ok(fx.alle(w, 'a').every((a) => !/^javascript/i.test(a.getAttribute('href') || '')))
}))
test('Website: Aktivität fällt aus, die Projekte bleiben', mit({ ...HAUPT, 'GET /api/activity': () => ({ status: 500, body: { error: 'Server kaputt' } }) }, async (fx) => {
const w = await zeige(fx)
assert.match(fx.text(w), /Aktivität konnte nicht geladen werden: Server kaputt/); assert.ok(fx.finde(w, '[data-projekt="p1"]'))
}))
test('Website: leer', mit({ ...HAUPT, 'GET /api/projects': { projects: [] } }, async (fx) => { assert.match(fx.text(await zeige(fx)), /Noch keine Website-Projekte zugewiesen\./) }))
test('Website: offene Rechnung steht als erste Zeile', mit({ ...HAUPT, 'GET /api/invoices': { invoices: [rechnung({ number: 'RE-2026-0042', balance: 50 })] } }, async (fx) => {
const w = await zeige(fx); assert.match(fx.finde(w, '.kn-rechnung-offen').textContent, /^Rechnung offen · RE-2026-0042/)
}))
test('Website: route.scroll scrollt genau einmal zur Aktivität', mit(HAUPT, async (fx) => {
let n = 0; const create = fx.document.createElement.bind(fx.document)
fx.document.createElement = (t) => { const el = create(t); if (t === 'section') el.scrollIntoView = () => { n += 1 }; return el }
await zeige(fx, { bereich: 'website', scroll: 'aktivitaet', query: '' }); assert.equal(n, 1)
}))
test('Etage projekt: Titel, drei Abschnitte, nur das gewählte Projekt (projekt= und id=)', mit({
...HAUPT, 'GET /api/projects': { projects: [projekt({ id: 'p1', projectName: 'Eins' }), projekt({ id: 'p2', projectName: 'Zwei' })] },
'GET /api/projects/p1/git': { changes: [] }, 'GET /api/projects/p1/files': { totalFiles: 0 }, 'GET /api/projects/p1/work': { order: null, entries: [] },
'GET /api/projects/p2/git': { changes: [] }, 'GET /api/projects/p2/files': { totalFiles: 0 }, 'GET /api/projects/p2/work': { order: null, entries: [] },
}, async (fx) => {
const titel = []; const w = neueWurzel(fx); w.addEventListener('kn:titel', (e) => titel.push(e.detail.text))
await zeigeWebsite(w, { route: { bereich: 'website', etage: 'projekt', query: 'projekt=p2' }, me: me(), nav: 'push' }); await fx.ruhe()
assert.deepEqual(titel, ['Zwei']); for (const t of ['Letzte Änderungen', 'Arbeiten am Projekt', 'Dateien']) assert.match(fx.text(w), new RegExp(t))
assert.ok(fx.fetchLog.some((a) => a.url === '/api/projects/p2/git')); assert.ok(!fx.fetchLog.some((a) => a.url === '/api/projects/p1/git'))
await zeigeWebsite(w, { route: { bereich: 'website', etage: 'projekt', query: 'id=p1' }, me: me(), nav: 'push' }); await fx.ruhe(); assert.deepEqual(titel, ['Zwei', 'Eins'])
}))
test('Website: Installationshinweis nur im Handy-Layout und noch nicht installiert', mit(HAUPT, async (fx) => {
fx.window.matchMedia = (q) => ({ matches: q.includes('max-width: 880px') }); fx.window.navigator = { userAgent: 'iPhone', maxTouchPoints: 5 }
assert.match(fx.text(await zeige(fx)), /WEBklar als App/)
}))
test('Website: ohne Handy-Layout kein Installationshinweis; späte Antwort eines früheren Aufrufs wird verworfen', mit(HAUPT, async (fx) => {
const w = await zeige(fx); assert.doesNotMatch(fx.text(w), /WEBklar als App/)
let frei; const gesperrt = new Promise((r) => { frei = r })
fx.tabelle['GET /api/projects'] = async () => { await gesperrt; return { status: 200, body: { projects: [projekt({ id: 'alt', projectName: 'Alt' })] } } }
const w2 = neueWurzel(fx)
const erster = zeigeWebsite(w2, { route: { bereich: 'website', query: '' }, me: me(), nav: 'tab' })
fx.tabelle['GET /api/projects'] = { projects: [projekt({ id: 'neu', projectName: 'Neu' })] }
await zeigeWebsite(w2, { route: { bereich: 'website', query: '' }, me: me(), nav: 'tab' }); frei(); await erster; await fx.ruhe()
assert.match(fx.text(w2), /Neu/); assert.doesNotMatch(fx.text(w2), /Alt/)
}))
test('Etage projekt ohne Projekte oder mit unbekannter ID: Text statt Fehler', mit({ ...HAUPT, 'GET /api/projects': { projects: [projekt({ id: 'p1', projectName: 'Eins' })] } }, async (fx) => {
const w = neueWurzel(fx)
await zeigeWebsite(w, { route: { bereich: 'website', etage: 'projekt', query: 'projekt=nope' }, me: me(), nav: 'push' }); await fx.ruhe()
assert.match(fx.text(w), /Dieses Projekt wurde nicht gefunden\./)
}))
+2 -2
View File
@@ -109,8 +109,8 @@ export const projekt = (over = {}) => ({
subdomain: 'baeckerei',
previewUrl: 'https://baeckerei.project.webklar.com',
liveDomain: '',
status: 'in_progress',
provisioningStatus: 'ready',
status: 'ready',
provisioningStatus: '',
updatedAt: '2026-10-14T09:00:00.000Z',
...over,
})