diff --git a/public/admin.js b/public/admin.js index e16309f..5d118e6 100644 --- a/public/admin.js +++ b/public/admin.js @@ -164,7 +164,7 @@ function renderActivity(tickets) { ${escapeHtml(t.status || '–')} ${escapeHtml(t.type || '')} · ${fmtDate(t.createdAt)} ${(t.worksheets || []).map((w) => ` -
→ ${fmtDate(w.date)} ${escapeHtml(w.employeeName)} (${escapeHtml(w.serviceType || '–')}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''}): ${escapeHtml((w.details || '').slice(0, 160))}
+
→ ${fmtDate(w.date)} ${escapeHtml(w.employeeName)} (${escapeHtml(w.serviceType || '–')}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''}${w.supportMinutes ? `, +${escapeHtml(w.supportMinutes)} min Unterstützung` : ''}): ${escapeHtml((w.details || '').slice(0, 160))}
`).join('')} `).join('') } diff --git a/public/app.js b/public/app.js index 6aa429b..7233675 100644 --- a/public/app.js +++ b/public/app.js @@ -249,7 +249,7 @@ function changeDayLabel(date) { } // Auftrag: Nummer, Betreff, Status und Fortschrittsleiste Eingegangen -> In Arbeit -> Prüfung -> Erledigt -function renderOrder(order) { +function renderOrder(order, totalMinutes = 0) { const { status } = order const steps = status.step > 0 ? `
    ${ORDER_STEPS.map((label, i) => { @@ -269,6 +269,7 @@ function renderOrder(order) { ${escapeHtml(status.label)} + ${totalMinutes > 0 ? `

    ${pdIcon('clock', 'pd-time-ico')}Arbeitszeit gesamt: ${fmtMinutes(totalMinutes)}

    ` : ''} ${steps}${hint}` } @@ -330,6 +331,9 @@ function renderWorkEntry(e, extra) { ${meta ? `${meta}` : ''} ${body} + ${e.supports?.length ? `` : ''} ${e.minutes ? `${pdIcon('clock', 'pd-time-ico')}${fmtMinutes(e.minutes)}` : ''} ` @@ -387,7 +391,7 @@ async function loadProjectDetails(project, box, { stillWanted = () => true } = { const order = work?.order box.innerHTML = ` - ${order ? `
    ${renderOrder(order)}
    ` : ''} + ${order ? `
    ${renderOrder(order, work?.totalMinutes || 0)}
    ` : ''} ${pdSection('changes', 'Letzte Änderungen', git ? renderChanges(git.changes || []) : '

    Die Änderungen können gerade nicht geladen werden.

    ')} diff --git a/public/kundenbereich.css b/public/kundenbereich.css index ae12b92..652c24e 100644 --- a/public/kundenbereich.css +++ b/public/kundenbereich.css @@ -848,3 +848,9 @@ button.kb-avatar:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } .kb-storage-summary .kb-btn-primary { width: 100%; } .kb-storage-error { color: #b3261e; } @media (min-width: 881px) { .kb-storage-summary .kb-btn-primary { width: auto; } } + +/* Unterstuetzung unter einem Arbeitsschritt und Gesamtzeit im Auftragskopf */ +.pd-supports { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 4px; } +.pd-support { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 10px; font-size: 12.5px; line-height: 1.5; color: var(--kb-faint); } +.pd-support .pd-time { font-size: 11.5px; padding: 2px 8px; } +.pd-total { display: flex; align-items: center; gap: 6px; margin: 10px 0 0; font-size: 13.5px; color: rgba(255, 255, 255, 0.82); } diff --git a/server/routes/projects.js b/server/routes/projects.js index 592c20f..093d24a 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -3,7 +3,7 @@ import { config } from '../config.js' import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { getProjectGitInsights, getProjectFileOverview } from '../services/projectInsights.js' -import { customerChanges, customerOrder, customerWorkEntries } from '../services/customerProjectView.js' +import { customerChanges, customerOrder, customerWorkEntries, workTotalMinutes } from '../services/customerProjectView.js' const router = Router() @@ -86,13 +86,15 @@ router.get('/:id/work', requireSession, loadOwnProject, async (req, res) => { return res.json({ order: null, entries: [] }) } + // Alle Blaetter, damit "Arbeitszeit gesamt" stimmt; lange Listen klappt der Browser zu (PD_ENTRIES_SHOWN) const worksheets = await listDocuments(config.collections.worksheets, [ Query.equal('workorderId', project.ticketId), Query.orderDesc('$createdAt'), - Query.limit(50), + Query.limit(5000), ]).catch(() => []) - return res.json({ order, entries: customerWorkEntries(worksheets, { includeUpdates: false }) }) + const entries = customerWorkEntries(worksheets, { includeUpdates: false }) + return res.json({ order, entries, totalMinutes: workTotalMinutes(entries) }) } catch (err) { return res.status(500).json({ error: err.message || 'Arbeiten konnten nicht geladen werden' }) } diff --git a/server/services/customerActivity.js b/server/services/customerActivity.js index afc088a..b482114 100644 --- a/server/services/customerActivity.js +++ b/server/services/customerActivity.js @@ -1,5 +1,6 @@ import { config } from '../config.js' import { listDocuments, Query } from './appwriteAdmin.js' +import { worksheetMinutes } from './worksheetMinutes.js' /** * Tickets + zugehoerige Worksheets eines Kunden ("was wurde gemacht"). @@ -40,6 +41,9 @@ export async function getCustomerActivity(customerId, { ticketLimit = 25, exclud serviceType: ws.serviceType || '', newStatus: ws.newStatus || '', totalTime: ws.totalTime || '', + // Unterstuetzung (WOMS): Rohliste fuer customerWorkEntries, Summe fuer die Admin-Zeile + supporters: ws.supporters || '', + supportMinutes: worksheetMinutes(ws) - (Number(ws.totalTime) || 0), date: ws.endDate || ws.startDate || '', details: ws.details || '', isGit: ws.serviceType === 'GIT', diff --git a/server/services/customerProjectView.js b/server/services/customerProjectView.js index cbec3e5..82c3ccd 100644 --- a/server/services/customerProjectView.js +++ b/server/services/customerProjectView.js @@ -5,6 +5,8 @@ * Admin-Ansichten nutzen weiter die Rohdaten. */ +import { parseSupporters, supportMinutes, firstName } from './worksheetMinutes.js' + const GENERIC_UPDATE = 'Website aktualisiert' /* --------------------------------------------------------------- Commits --- */ @@ -204,15 +206,20 @@ function customerWorkEntry(sheet, { includeUpdates }) { const [kind, label] = WORK_KINDS[type] || OTHER_WORK const date = isoDay(sheet.date || sheet.endDate || sheet.startDate) const minutes = Number(sheet.totalTime) || 0 + // Unterstuetzung: eigene Zeilen, je Person die Haelfte der Arbeitszeit (aufgerundet auf 15 min) + const supports = parseSupporters(sheet.supporters, sheet.$id) + .map((s) => ({ name: firstName(s.name), task: s.task, minutes: supportMinutes(minutes) })) + .filter((s) => s.minutes > 0) + const withSupports = (entry) => (supports.length ? { ...entry, supports } : entry) if (isGit) { // In den Projektdetails stehen die Aenderungen schon unter "Letzte Änderungen"; // dort zaehlt ein Push nur, wenn Arbeitszeit nachgetragen wurde if (!includeUpdates && !minutes) return null const changes = changesFromPushDetails(sheet.details) - return changes.length ? { kind, label, date, minutes, changes } : null + return changes.length ? withSupports({ kind, label, date, minutes, changes }) : null } - return { kind, label, date, minutes, person: String(sheet.employeeName || '').trim(), text: String(sheet.details || '').trim() } + return withSupports({ kind, label, date, minutes, person: String(sheet.employeeName || '').trim(), text: String(sheet.details || '').trim() }) } /** Worksheets (roh aus Appwrite oder aus getCustomerActivity) -> Eintraege fuer den Kunden. */ @@ -225,9 +232,18 @@ export function customerWorkEntries(sheets = [], { includeUpdates = true } = {}) if (entry.kind === 'update' && prev?.kind === 'update' && prev.date === entry.date) { prev.minutes += entry.minutes prev.changes.push(...entry.changes.filter((change) => !prev.changes.includes(change))) + if (entry.supports) prev.supports = [...(prev.supports || []), ...entry.supports] continue } entries.push(entry) } return entries } + +/** Arbeitszeit gesamt fuer den Projektkopf: alle Schritte inkl. Unterstuetzung */ +export function workTotalMinutes(entries = []) { + return entries.reduce( + (sum, e) => sum + (Number(e.minutes) || 0) + (e.supports || []).reduce((s, x) => s + (Number(x.minutes) || 0), 0), + 0 + ) +} diff --git a/test/customerProjectView.test.js b/test/customerProjectView.test.js index d0032da..00c9387 100644 --- a/test/customerProjectView.test.js +++ b/test/customerProjectView.test.js @@ -6,6 +6,7 @@ import { isInternalTicket, customerOrder, customerWorkEntries, + workTotalMinutes, } from '../server/services/customerProjectView.js' // Commit wie listRepoCommits() ihn liefert (giteaAdmin.js) @@ -309,3 +310,56 @@ test('Arbeit: Einträge aus getCustomerActivity (date statt endDate, Zeit als Te { kind: 'online', label: 'Online erledigt', date: '2026-09-18', minutes: 30, person: 'Andrej Stevanovski', text: 'Demo-Version erstellt' }, ]) }) + +/* ---------------------------------------------------- Unterstützung --- */ + +const andrej = { employeeId: 'andrej', name: 'Andrej Stevanovski', task: 'Farbkonzept' } +const nico = { employeeId: 'nico', name: 'Nico Rech', task: 'Texte' } +const workSheet = (extra = {}) => ({ + serviceType: 'Remote', employeeName: 'Justin Klein', totalTime: 60, startDate: '30.09.2026', endDate: '30.09.2026', + details: 'Startseite umgebaut', isComment: false, ...extra, +}) + +test('Unterstützung: eigene Zeile mit Vorname, Tätigkeit und halber Zeit', () => { + const [entry] = customerWorkEntries([workSheet({ supporters: JSON.stringify([andrej]) })]) + assert.equal(entry.minutes, 60) + assert.deepEqual(entry.supports, [{ name: 'Andrej', task: 'Farbkonzept', minutes: 30 }]) +}) + +test('Unterstützung: ohne supporters kein supports-Feld (bisherige Form bleibt)', () => { + const [entry] = customerWorkEntries([workSheet()]) + assert.equal('supports' in entry, false) +}) + +test('Unterstützung: zusammengefasste Git-Tage sammeln alle Unterstützungen', () => { + const push = (sha, title, sup) => ({ + ...gitSheet, endDate: '24.09.2026', totalTime: 30, startTime: '1300', isComment: false, supporters: JSON.stringify([sup]), + details: `Git Push auf \`knso/ff-lack\` (Branch: main)\nCommit: ${sha}\n\n- [${sha}](https://git.webklar.com/knso/ff-lack/commit/${sha}): ${title} (knso)`, + }) + const entries = customerWorkEntries([push('aaaaaaa', 'Handy-Ansicht', andrej), push('bbbbbbb', 'Icons', nico)], { includeUpdates: false }) + assert.equal(entries.length, 1) + assert.equal(entries[0].minutes, 60) + assert.deepEqual(entries[0].supports, [ + { name: 'Andrej', task: 'Farbkonzept', minutes: 15 }, + { name: 'Nico', task: 'Texte', minutes: 15 }, + ]) +}) + +test('Unterstützung: Kommentar mit supporters erscheint nicht', () => { + assert.deepEqual(customerWorkEntries([workSheet({ isComment: true, supporters: JSON.stringify([andrej]) })]), []) +}) + +test('Unterstützung: kaputtes supporters -> Eintrag ohne Unterstützung', () => { + const [entry] = customerWorkEntries([workSheet({ supporters: 'kaputt' })]) + assert.equal(entry.minutes, 60) + assert.equal('supports' in entry, false) +}) + +test('workTotalMinutes: Summe aller Schritte inkl. Unterstützung', () => { + const entries = customerWorkEntries([ + workSheet({ supporters: JSON.stringify([andrej, nico]) }), + workSheet({ totalTime: 45 }), + ]) + assert.equal(workTotalMinutes(entries), 60 + 30 + 30 + 45) + assert.equal(workTotalMinutes([]), 0) +}) diff --git a/test/projectWork.test.js b/test/projectWork.test.js new file mode 100644 index 0000000..c0b78a3 --- /dev/null +++ b/test/projectWork.test.js @@ -0,0 +1,42 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +const aw = await startAppwriteMock() +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { startTestApp, get, sessionCookie } = await import('./support/testApp.js') +const { default: projectsRouter } = await import('../server/routes/projects.js') +const app = await startTestApp({ '/api/projects': projectsRouter }) +after(() => Promise.all([aw.close(), st.close(), app.close()])) + +aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', ticketId: 'wo1', name: 'Website' }) +aw.seed('workorders', { $id: 'wo1', woid: '100200', status: 'Open', type: 'Webpage', topic: 'Neue Website', customerId: 'c1' }) +const andrej = { employeeId: 'andrej', name: 'Andrej Stevanovski', task: 'Farbkonzept' } +const sheet = (id, extra) => ({ + $id: id, workorderId: 'wo1', woid: '100200', serviceType: 'Remote', employeeName: 'Justin Klein', totalTime: 60, + startDate: '30.09.2026', endDate: '30.09.2026', details: 'Arbeit', isComment: false, ...extra, +}) +// Neuestes Blatt, damit es auch in getCustomerActivity (max. 20 je Ticket) dabei ist +aw.seed('worksheets', sheet('ws1', { supporters: JSON.stringify([andrej]), $createdAt: '2026-09-30T18:00:00.000Z' })) +// Mehr als 50 Blaetter: die Gesamtzeit zaehlt trotzdem alle +for (let i = 0; i < 55; i++) aw.seed('worksheets', sheet(`wsx${i}`, { totalTime: 15 })) + +test('GET /:id/work liefert totalMinutes inkl. Unterstützung über alle Blätter', async () => { + const body = await get('/api/projects/p1/work', { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) }) + assert.equal(body.totalMinutes, 60 + 30 + 55 * 15) + const withSupport = body.entries.find((e) => e.supports) + assert.deepEqual(withSupport.supports, [{ name: 'Andrej', task: 'Farbkonzept', minutes: 30 }]) +}) + +test('getCustomerActivity reicht supporters und supportMinutes weiter (Fortschritt + Admin)', async () => { + const { getCustomerActivity } = await import('../server/services/customerActivity.js') + const [ticket] = await getCustomerActivity('c1') + const ws1 = ticket.worksheets.find((w) => w.supporters) + assert.equal(ws1.supportMinutes, 30) + const { customerWorkEntries } = await import('../server/services/customerProjectView.js') + const entry = customerWorkEntries(ticket.worksheets).find((e) => e.supports) + assert.deepEqual(entry.supports, [{ name: 'Andrej', task: 'Farbkonzept', minutes: 30 }]) +})