Kundenansicht neu P2: Ansicht Website mit Projektdetails als Etage
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e036d24ae5
commit
a3831040ba
4 files changed
+208
-11
No files matched your search
@@ -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' })
|
||||
}
|
||||
@@ -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.')))
|
||||
}
|
||||
@@ -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\./)
|
||||
}))
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user