Merge kn/auftraege-pilot (Pilot-Feedback 17.10.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-17 15:43:07 +00:00
commit 5f4e158660
10 files changed
+544 -36

No files matched your search

+2 -1
View File
@@ -4,7 +4,8 @@
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, ersetze, zuAnsicht, meldeTitel, fmtDatumZeit, sichereAdresse } from './ansicht-hilfen.js'
import { baueAktivitaet, baueProjektDetails, ladeProjektDetails } from './website-details.js'
import { baueProjektDetails, ladeProjektDetails } from './website-details.js'
import { baueAktivitaet } from './auftrags-zeitleiste.js'
import { ladeOffeneRechnungen, rechnungOffenZeile } from './rechnung-offen.js'
import { starteInstallQuelle, installAngebot, baueInstallHinweis, istAusgeblendet, merkeAusgeblendet } from './install-hinweis.js'
import { HANDY_ABFRAGE } from './leiste.js'
+30 -3
View File
@@ -85,10 +85,7 @@
.kn-projekt-links { display: flex; flex-wrap: wrap; gap: 8px; }
/* Auftrag, Änderungen, Arbeiten, Aktivität */
.kn-aktivitaet { margin: 0; }
[data-abschnitt="aktivitaet"] { scroll-margin-top: 16px; } /* #fortschritt: scrollIntoView lässt den Abschnitt nicht hinter dem haftenden Kopf beginnen (am Handy unten) */
.kn-auftrag-karte { padding: 16px 0; border-top: 1px solid var(--kn-line); }
.kn-auftrag-karte > .kn-auftrag + * { margin-top: 12px; }
.kn-auftrag-kopf { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.kn-auftrag-name { min-width: 0; }
.kn-auftrag-nr { display: block; font-size: 12px; color: var(--kn-text-2); font-variant-numeric: tabular-nums; }
@@ -113,6 +110,33 @@
.kn-eintrag-meta { font-size: 13px; color: var(--kn-text-2); }
.kn-eintrag-text { margin: 4px 0 0; color: var(--kn-text-2); font-size: 13px; }
.kn-unterstuetzung li { display: flex; justify-content: space-between; gap: 12px; margin-top: 4px; font-size: 13px; color: var(--kn-text-2); }
/* „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C): je Auftrag Überschrift mit Pille, schmaler Balken in vier Abschnitten, Zeitleiste mit Haarlinie */
.kn-az-liste { margin: 0; }
.kn-az-liste:not(.is-open) > .kn-az-auftraege > [data-extra] { display: none; } /* nur direkte Kinder: die Zeitleisten darin klappen für sich */
.kn-az-auftraege { list-style: none; margin: 0; padding: 0; }
.kn-az { padding: 20px 0; border-top: 1px solid var(--kn-line); }
.kn-az-kopf { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 6px 12px; }
.kn-az-titel { flex: 1 1 12em; min-width: 0; margin: 0; font-size: 16px; line-height: 24px; font-weight: 600; overflow-wrap: anywhere; }
.kn-az-kopf .kn-pille { flex: none; margin-top: 2px; }
.kn-az-info { margin: 8px 0 0; font-size: 14px; line-height: 20px; }
.kn-az-info a { color: var(--kn-orange-fg); text-decoration: none; }
.kn-az-info-link { font-weight: 600; text-decoration: underline; text-decoration-color: currentColor; text-underline-offset: 3px; }
.kn-az-balken { display: flex; gap: 3px; margin: 12px 0 0; padding: 0; list-style: none; }
.kn-az-balken li { position: relative; flex: 1; height: 4px; }
.kn-az-balken li::before { content: ""; position: absolute; inset: 0; border-radius: 2px; background: var(--kn-line); } /* die Fläche liegt im Pseudo-Element: der Lesetext darunter bleibt auf Weiß */
.kn-az-balken li.is-done::before { background: var(--kn-accent); }
.kn-nur-lesen { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
.kn-az-zeitleiste { margin: 14px 0 0; }
.kn-az-zeilen { list-style: none; margin: 0 0 0 3px; padding: 0; border-left: 1px solid var(--kn-line-strong); }
.kn-az-zeilen li { position: relative; display: flex; gap: 10px; padding: 4px 0 4px 16px; font-size: 14px; line-height: 20px; }
.kn-az-zeilen li::before { content: ""; position: absolute; left: -3px; top: 12px; width: 5px; height: 5px; border-radius: 50%; background: var(--kn-line-strong); }
.kn-az-datum { flex: none; min-width: 3.2em; color: var(--kn-text-2); font-variant-numeric: tabular-nums; white-space: nowrap; }
.kn-az-mit-jahr .kn-az-datum { min-width: 4.4em; }
.kn-az-text { min-width: 0; overflow-wrap: anywhere; }
.kn-az-zeitleiste:not(.is-open) > .kn-az-zeilen > [data-extra] { display: none; }
.kn-az-mehr { margin: 4px 0 0 8px; }
.kn-az-leer { margin: 12px 0 0; font-size: 14px; color: var(--kn-text-2); }
.kn-az-aeltere { margin: 8px 0 0; }
.kn-dateien { margin: 0; }
.kn-details { margin: 0; }
@@ -361,6 +385,9 @@
.kn-zeilenlink { display: inline-flex; align-items: center; min-height: 44px; }
/* Pflicht- und Zahlungslinks (E-Fix F3): Verträge hier kündigen, Vertrag widerrufen, Vertragsbedingungen, Zahlen in der Zeile „Rechnung offen“ */
.kn-kuendigen a, .kn-widerruf a, .kn-links a, .kn-rechnung-offen a { display: inline-flex; align-items: center; min-height: 44px; }
.kn-az-info { margin-top: 0; }
.kn-az-info a { display: block; padding: 12px 0; }
.kn-az-info + .kn-az-balken { margin-top: 0; }
.kn-rechnung-offen { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.kn-rechnung-offen .kn-trenner { display: none; }
.kn-rechnung-offen a { flex: none; font-weight: 600; }
+176
View File
@@ -0,0 +1,176 @@
/* Kundenansicht neu: „Was wir zuletzt erledigt haben“ als Zeitleiste (Variante C, Pilot-Feedback 17.10.).
Je Auftrag aus GET /api/activity: Überschrift mit Status-Pille, schmaler Fortschrittsbalken, bei Rückfrage der Hinweis zum Chat und eine
kompakte Zeitleiste der Einträge. Die Logik (Reihenfolge, Gruppierung, Kürzen, Fortschritt, Kurztexte) sind reine Funktionen; gezeichnet wird
mit h() und Textknoten, nie als HTML. Kein DOM-Zugriff beim Import.
Antwortform laut server/routes/customerData.js und services/customerProjectView.js:
{ number, topic, status: { label, tone, step }, createdAt, entries: [{ kind, label, date: 'JJJJ-MM-TT', minutes, person, text | changes }] }. */
import { h } from './dom.js'
import { K, berlinTag, zuAnsicht } from './ansicht-hilfen.js'
import { AUFTRAG_SCHRITTE, statusTon } from './website-details.js'
/** Die vier Abschnitte des Balkens (dieselben Namen wie der Fortschritt der Projektdetails). */
export const STUFEN = AUFTRAG_SCHRITTE
/** So viele Aufträge stehen offen, der Rest hinter „Ältere Aufträge anzeigen“. */
export const MAX_AUFTRAEGE = 5
/** So viele Zeilen der Zeitleiste stehen offen, der Rest hinter „mehr anzeigen“. */
export const MAX_ZEILEN = 3
const MAX_KURZTEXT = 90
/* ---------------------------------------------------------------- Fortschritt --- */
/** Die vier Abschnitte des Balkens: erreicht bis zum aktuellen Stand; Stufe 0 (pausiert, storniert) und Unbekanntes erreichen nichts. */
export function fortschritt(status) {
const schritt = Number(status?.step)
const gueltig = Number.isInteger(schritt) && schritt >= 1 && schritt <= STUFEN.length
return STUFEN.map((name, i) => ({ name, erreicht: gueltig && i < schritt, aktuell: gueltig && i === schritt - 1 }))
}
/** Braucht der Auftrag eine Info vom Kunden? Der Server meldet das mit dem Ton „waiting“ (Ticketstatus „awaiting“, „Wartet auf deine Rückmeldung“). */
export const braucheInfo = (auftrag) => auftrag?.status?.tone === 'waiting'
/* ---------------------------------------------------------- Reihenfolge, Kürzen --- */
const eintraegeVon = (auftrag) => (Array.isArray(auftrag?.entries) ? auftrag.entries.filter((e) => e && typeof e === 'object') : [])
// 'JJJJ-MM-TT' (auch als Anfang eines ISO-Zeitpunkts) → { j, m, t } ohne Zeitzonen-Umrechnung; sonst null
function tagTeile(wert) {
const treffer = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(wert ?? ''))
if (!treffer) return null
const [j, m, t] = [Number(treffer[1]), Number(treffer[2]), Number(treffer[3])]
return m >= 1 && m <= 12 && t >= 1 && t <= 31 ? { j, m, t } : null
}
const zweistellig = (zahl) => String(zahl).padStart(2, '0')
const isoTag = (wert) => { const tag = tagTeile(wert); return tag ? `${tag.j}-${zweistellig(tag.m)}-${zweistellig(tag.t)}` : '' }
/** Zeitpunkt der jüngsten Aktivität (ms): der späteste Eintrag oder die Anlage des Auftrags; 0, wenn nichts lesbar ist. */
export function letzteAktivitaet(auftrag) {
const zeiten = [Date.parse(String(auftrag?.createdAt ?? '')), ...eintraegeVon(auftrag).map((e) => { const tag = isoTag(e.date); return tag ? Date.parse(`${tag}T00:00:00Z`) : NaN })]
return Math.max(0, ...zeiten.filter((zeit) => Number.isFinite(zeit)))
}
// 0 = laufend (auch pausiert), 1 = erledigt, 2 = storniert oder abgebrochen
const rang = (auftrag) => (auftrag?.status?.tone === 'done' ? 1 : auftrag?.status?.tone === 'stopped' ? 2 : 0)
/** Laufende Aufträge zuerst, dann erledigte, Storniertes zuletzt; innerhalb jeder Gruppe die jüngste Aktivität vorn, bei Gleichstand die Reihenfolge des Servers. */
export function sortiereAuftraege(tickets) {
const liste = Array.isArray(tickets) ? tickets.filter((t) => t && typeof t === 'object') : []
return liste
.map((auftrag, i) => ({ auftrag, i, rang: rang(auftrag), zeit: letzteAktivitaet(auftrag) }))
.sort((a, b) => a.rang - b.rang || b.zeit - a.zeit || a.i - b.i)
.map((e) => e.auftrag)
}
/** Die ersten n sichtbar, der Rest getrennt; die Eingabe bleibt unverändert. */
export function kuerze(liste, max) {
const alle = Array.isArray(liste) ? liste : []
return { sichtbar: alle.slice(0, max), rest: alle.slice(max) }
}
/** Sortierte Aufträge: höchstens max offen, der Rest als „älter“. */
export function gruppiereAuftraege(tickets, max = MAX_AUFTRAEGE) {
const { sichtbar, rest } = kuerze(sortiereAuftraege(tickets), max)
return { sichtbar, aeltere: rest }
}
/* ---------------------------------------------------------------- Zeitleiste --- */
/** 'TT.MM.' für einen Tag 'JJJJ-MM-TT'; fällt er nicht ins laufende Jahr, steht 'TT.MM.JJ' da. Unlesbares ergibt ''. */
export function datumKurz(wert, jetzt = new Date()) {
const tag = tagTeile(wert)
if (!tag) return ''
const laufend = berlinTag(jetzt)?.j ?? jetzt.getUTCFullYear()
const jahr = tag.j === laufend ? '' : zweistellig(tag.j % 100)
return `${zweistellig(tag.t)}.${zweistellig(tag.m)}.${jahr}`
}
// erste nichtleere Zeile, Leerraum zusammengezogen
const ersteZeile = (text) => String(text ?? '').split(/\r?\n/).map((zeile) => zeile.replace(/\s+/g, ' ').trim()).find(Boolean) ?? ''
function kuerzeText(text, max) {
if (text.length <= max) return text
const schnitt = text.slice(0, max)
const leer = schnitt.lastIndexOf(' ')
return `${(leer > max * 0.6 ? schnitt.slice(0, leer) : schnitt).replace(/[\s,;:.–-]+$/, '')}…`
}
/** Kurzer Text einer Zeile: die erste Änderung (mit „und n weitere“), sonst der freie Text der Arbeit, sonst die Art der Arbeit. Nie lang, nie ein Name. */
export function eintragsKurztext(eintrag) {
const aenderungen = Array.isArray(eintrag?.changes) ? eintrag.changes.map(ersteZeile).filter(Boolean) : []
if (aenderungen.length) {
const rest = aenderungen.length - 1
const erste = kuerzeText(aenderungen[0], MAX_KURZTEXT)
return rest ? `${erste} und ${rest} weitere ${rest === 1 ? 'Änderung' : 'Änderungen'}` : erste
}
return kuerzeText(ersteZeile(eintrag?.text), MAX_KURZTEXT) || ersteZeile(eintrag?.label) || 'Arbeit am Projekt'
}
/** Zeilen der Zeitleiste eines Auftrags, neueste zuerst; Einträge ohne lesbares Datum stehen unten. */
export function zeitleistenZeilen(auftrag, jetzt = new Date()) {
return eintraegeVon(auftrag)
.map((e, i) => ({ i, iso: isoTag(e.date), datum: datumKurz(e.date, jetzt), text: eintragsKurztext(e) }))
.sort((a, b) => (a.iso === b.iso ? a.i - b.i : !a.iso ? 1 : !b.iso ? -1 : b.iso.localeCompare(a.iso)))
.map(({ datum, iso, text }) => ({ datum, iso, text }))
}
/* ------------------------------------------------------------------------ DOM --- */
const LEER = 'Es wurden noch keine Arbeiten dokumentiert.'
const KEINE_EINTRAEGE = 'Noch keine Arbeiten dokumentiert.'
/** Knopf, der den nächsten Wrapper [data-einklappbar] auf- und zuklappt (is-open); die Zeilen dahinter tragen [data-extra]. Die CSS-Regeln dazu gelten nur für
direkte Kinder (ansichten.css, .kn-az-…): die allgemeine Regel .kn-einklappbar würde bei verschachtelten Wrappern auch offene Zeilen verbergen. */
function umschalter({ attribut, klasse, mehr, weniger }) {
const knopf = h('button', { type: 'button', class: klasse, [attribut]: true, 'aria-expanded': 'false' }, mehr)
knopf.addEventListener('click', () => {
const offen = knopf.closest('[data-einklappbar]').classList.toggle('is-open')
knopf.textContent = offen ? weniger : mehr
knopf.setAttribute('aria-expanded', String(offen))
})
return knopf
}
const zustandText = (stufe) => (stufe.aktuell ? 'aktueller Stand' : stufe.erreicht ? 'erreicht' : 'noch offen')
// Vier Abschnitte, erreichte schwarz; die Namen stehen nur für Screenreader als Text. Ohne erreichte Stufe (pausiert, storniert) gibt es keinen Balken.
function baueBalken(status) {
const stufen = fortschritt(status)
if (!stufen.some((stufe) => stufe.erreicht)) return null
return h('ol', { class: 'kn-az-balken', 'aria-label': 'Fortschritt des Auftrags' }, stufen.map((stufe) => h('li',
{ class: stufe.erreicht ? 'is-done' : null, 'aria-current': stufe.aktuell ? 'step' : null },
h('span', { class: 'kn-nur-lesen' }, `${stufe.name}, ${zustandText(stufe)}`))))
}
function baueZeitleiste(zeilen) {
const { sichtbar, rest } = kuerze(zeilen, MAX_ZEILEN)
const zeile = (z, extra) => h('li', { 'data-extra': extra },
z.datum ? h('time', { class: 'kn-az-datum', datetime: z.iso || null }, z.datum) : h('span', { class: 'kn-az-datum', 'aria-hidden': 'true' }),
h('span', { class: 'kn-az-text' }, z.text))
return h('div', { class: 'kn-az-zeitleiste', 'data-einklappbar': true },
// Trägt ein Datum das Jahr (TT.MM.JJ), bekommt die Datumsspalte aller Zeilen die Breite dafür, damit die Texte bündig bleiben
h('ol', { class: zeilen.some((z) => z.datum.length > 6) ? 'kn-az-zeilen kn-az-mit-jahr' : 'kn-az-zeilen' }, sichtbar.map((z) => zeile(z, false)), rest.map((z) => zeile(z, true))),
rest.length ? umschalter({ attribut: 'data-mehr', klasse: 'kn-textknopf kn-az-mehr', mehr: 'mehr anzeigen', weniger: 'weniger anzeigen' }) : null)
}
function baueAuftragsBlock(auftrag, jetzt, extra) {
const status = auftrag.status && typeof auftrag.status === 'object' ? auftrag.status : {}
const zeilen = zeitleistenZeilen(auftrag, jetzt)
return h('li', { class: 'kn-az', 'data-auftrag': auftrag.number || null, 'data-extra': extra },
h('div', { class: 'kn-az-kopf' },
h('h3', { class: 'kn-az-titel' }, auftrag.topic || 'Dein Auftrag'),
status.label ? h('span', { class: K.pille[statusTon(status.tone)] }, status.label) : null),
// Die ganze Zeile führt in den Chat (große Tippfläche am Handy); unterstrichen ist nur „Im Chat antworten“
braucheInfo(auftrag) ? h('p', { class: 'kn-az-info' }, h('a', zuAnsicht('chat'), 'Wir brauchen eine Info von dir · ', h('span', { class: 'kn-az-info-link' }, 'Im Chat antworten'))) : null,
baueBalken(status),
zeilen.length ? baueZeitleiste(zeilen) : h('p', { class: 'kn-az-leer' }, KEINE_EINTRAEGE))
}
/** „Was wir zuletzt erledigt haben“: ein Block je Auftrag; fünf offen, der Rest hinter „Ältere Aufträge anzeigen“. Ohne Aufträge ein ruhiger Satz. */
export function baueAktivitaet(tickets, jetzt = new Date()) {
const { sichtbar, aeltere } = gruppiereAuftraege(tickets)
if (!sichtbar.length) return h('p', { class: 'kn-hinweis' }, LEER)
return h('div', { class: 'kn-az-liste', 'data-einklappbar': true },
h('ul', { class: 'kn-az-auftraege' }, sichtbar.map((a) => baueAuftragsBlock(a, jetzt, false)), aeltere.map((a) => baueAuftragsBlock(a, jetzt, true))),
aeltere.length ? umschalter({ attribut: 'data-aeltere', klasse: 'kn-knopf kn-az-aeltere', mehr: 'Ältere Aufträge anzeigen', weniger: 'Weniger anzeigen' }) : null)
}
+1 -20
View File
@@ -1,4 +1,4 @@
/* Kundenansicht neu (P2): Bausteine der Projektdetails und der Aktivität (Auftrag, Änderungen, Arbeiten, Dateien).
/* Kundenansicht neu (P2): Bausteine der Projektdetails (Auftrag, Änderungen, Arbeiten, Dateien); die Aktivität der Website-Seite steht in auftrags-zeitleiste.js.
Alles über h() und Textknoten, nie als HTML. Kein DOM-Zugriff beim Import. */
import { api } from './api.js'
@@ -8,7 +8,6 @@ import { K, zuAnsicht, fmtBytes, fmtMinuten, fmtUhrzeit, berlinTag } from './ans
export const AUFTRAG_SCHRITTE = ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt']
const ANZAHL_AENDERUNGEN = 5
const ANZAHL_EINTRAEGE = 4
const ANZAHL_AKTIVITAET = 3
const TON = { new: 'grau', active: 'blau', waiting: 'orange', review: 'violett', done: 'gruen', paused: 'grau', stopped: 'rot' }
/** Farbton der Status-Pille zum Ton des Servers. */
@@ -136,24 +135,6 @@ export function baueDateien(files) {
h('tbody', null, zeilen))))
}
/** Aktivität: je Auftrag Kopf und Arbeiten, die drei neuesten sichtbar, „Alle anzeigen (n)“ zeigt den Rest. */
export function baueAktivitaet(tickets, jetzt = new Date()) {
const liste = Array.isArray(tickets) ? tickets : []
if (!liste.length) return leerText('Es wurden noch keine Arbeiten dokumentiert.')
const sortiert = [...liste].sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')))
const behaelter = h('div', { class: 'kn-aktivitaet' })
const zeichne = (alle) => {
const knopf = sortiert.length > ANZAHL_AKTIVITAET
? h('button', { type: 'button', class: K.knopf, on: { click: () => zeichne(!alle) } }, alle ? 'Weniger anzeigen' : `Alle anzeigen (${sortiert.length})`)
: null
behaelter.replaceChildren(
...sortiert.slice(0, alle ? sortiert.length : ANZAHL_AKTIVITAET).map((t) => h('article', { class: 'kn-auftrag-karte' }, baueAuftrag(t), baueArbeiten(t.entries, 'Noch keine Arbeiten dokumentiert.'))),
...(knopf ? [knopf] : []))
}
zeichne(false)
return behaelter
}
/** Die drei Quellen der Projektdetails; jede einzeln, ein Fehler ergibt null. */
export async function ladeProjektDetails(projectId, apiFn = api) {
const id = encodeURIComponent(projectId)