Unterstützung: eigene Zeilen und Gesamtzeit in den Projektdetails
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7c6b0c906b
commit
7da4452bbd
8 files changed
+136
-8
No files matched your search
+1
-1
@@ -164,7 +164,7 @@ function renderActivity(tickets) {
|
|||||||
<span class="pill pill-muted">${escapeHtml(t.status || '–')}</span>
|
<span class="pill pill-muted">${escapeHtml(t.status || '–')}</span>
|
||||||
<span class="muted">${escapeHtml(t.type || '')} · ${fmtDate(t.createdAt)}</span>
|
<span class="muted">${escapeHtml(t.type || '')} · ${fmtDate(t.createdAt)}</span>
|
||||||
${(t.worksheets || []).map((w) => `
|
${(t.worksheets || []).map((w) => `
|
||||||
<div class="ws-line">→ ${fmtDate(w.date)} ${escapeHtml(w.employeeName)} (${escapeHtml(w.serviceType || '–')}${w.totalTime ? `, ${escapeHtml(w.totalTime)}` : ''}): ${escapeHtml((w.details || '').slice(0, 160))}</div>
|
<div class="ws-line">→ ${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))}</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>`).join('')
|
</div>`).join('')
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -249,7 +249,7 @@ function changeDayLabel(date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auftrag: Nummer, Betreff, Status und Fortschrittsleiste Eingegangen -> In Arbeit -> Prüfung -> Erledigt
|
// Auftrag: Nummer, Betreff, Status und Fortschrittsleiste Eingegangen -> In Arbeit -> Prüfung -> Erledigt
|
||||||
function renderOrder(order) {
|
function renderOrder(order, totalMinutes = 0) {
|
||||||
const { status } = order
|
const { status } = order
|
||||||
const steps = status.step > 0
|
const steps = status.step > 0
|
||||||
? `<ol class="pd-steps" aria-label="Fortschritt des Auftrags">${ORDER_STEPS.map((label, i) => {
|
? `<ol class="pd-steps" aria-label="Fortschritt des Auftrags">${ORDER_STEPS.map((label, i) => {
|
||||||
@@ -269,6 +269,7 @@ function renderOrder(order) {
|
|||||||
</div>
|
</div>
|
||||||
<span class="pd-status">${escapeHtml(status.label)}</span>
|
<span class="pd-status">${escapeHtml(status.label)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${totalMinutes > 0 ? `<p class="pd-total">${pdIcon('clock', 'pd-time-ico')}Arbeitszeit gesamt: <strong>${fmtMinutes(totalMinutes)}</strong></p>` : ''}
|
||||||
${steps}${hint}`
|
${steps}${hint}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +331,9 @@ function renderWorkEntry(e, extra) {
|
|||||||
${meta ? `<span class="pd-entry-meta">${meta}</span>` : ''}
|
${meta ? `<span class="pd-entry-meta">${meta}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${body}
|
${body}
|
||||||
|
${e.supports?.length ? `<ul class="pd-supports">${e.supports.map((x) => `
|
||||||
|
<li class="pd-support"><span>↳ Unterstützung ${escapeHtml(x.name)}: ${escapeHtml(x.task)}</span>
|
||||||
|
<span class="pd-time" title="Angerechnete Zeit der Unterstützung">${pdIcon('clock', 'pd-time-ico')}${fmtMinutes(x.minutes)}</span></li>`).join('')}</ul>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${e.minutes ? `<span class="pd-time" title="Arbeitszeit">${pdIcon('clock', 'pd-time-ico')}${fmtMinutes(e.minutes)}</span>` : ''}
|
${e.minutes ? `<span class="pd-time" title="Arbeitszeit">${pdIcon('clock', 'pd-time-ico')}${fmtMinutes(e.minutes)}</span>` : ''}
|
||||||
</li>`
|
</li>`
|
||||||
@@ -387,7 +391,7 @@ async function loadProjectDetails(project, box, { stillWanted = () => true } = {
|
|||||||
const order = work?.order
|
const order = work?.order
|
||||||
|
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
${order ? `<section class="pd-order pd-tone-${escapeAttr(order.status.tone)}">${renderOrder(order)}</section>` : ''}
|
${order ? `<section class="pd-order pd-tone-${escapeAttr(order.status.tone)}">${renderOrder(order, work?.totalMinutes || 0)}</section>` : ''}
|
||||||
${pdSection('changes', 'Letzte Änderungen', git
|
${pdSection('changes', 'Letzte Änderungen', git
|
||||||
? renderChanges(git.changes || [])
|
? renderChanges(git.changes || [])
|
||||||
: '<p class="pd-empty">Die Änderungen können gerade nicht geladen werden.</p>')}
|
: '<p class="pd-empty">Die Änderungen können gerade nicht geladen werden.</p>')}
|
||||||
|
|||||||
@@ -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-summary .kb-btn-primary { width: 100%; }
|
||||||
.kb-storage-error { color: #b3261e; }
|
.kb-storage-error { color: #b3261e; }
|
||||||
@media (min-width: 881px) { .kb-storage-summary .kb-btn-primary { width: auto; } }
|
@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); }
|
||||||
@@ -3,7 +3,7 @@ import { config } from '../config.js'
|
|||||||
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
|
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||||
import { getProjectGitInsights, getProjectFileOverview } from '../services/projectInsights.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()
|
const router = Router()
|
||||||
|
|
||||||
@@ -86,13 +86,15 @@ router.get('/:id/work', requireSession, loadOwnProject, async (req, res) => {
|
|||||||
return res.json({ order: null, entries: [] })
|
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, [
|
const worksheets = await listDocuments(config.collections.worksheets, [
|
||||||
Query.equal('workorderId', project.ticketId),
|
Query.equal('workorderId', project.ticketId),
|
||||||
Query.orderDesc('$createdAt'),
|
Query.orderDesc('$createdAt'),
|
||||||
Query.limit(50),
|
Query.limit(5000),
|
||||||
]).catch(() => [])
|
]).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) {
|
} catch (err) {
|
||||||
return res.status(500).json({ error: err.message || 'Arbeiten konnten nicht geladen werden' })
|
return res.status(500).json({ error: err.message || 'Arbeiten konnten nicht geladen werden' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { listDocuments, Query } from './appwriteAdmin.js'
|
import { listDocuments, Query } from './appwriteAdmin.js'
|
||||||
|
import { worksheetMinutes } from './worksheetMinutes.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tickets + zugehoerige Worksheets eines Kunden ("was wurde gemacht").
|
* Tickets + zugehoerige Worksheets eines Kunden ("was wurde gemacht").
|
||||||
@@ -40,6 +41,9 @@ export async function getCustomerActivity(customerId, { ticketLimit = 25, exclud
|
|||||||
serviceType: ws.serviceType || '',
|
serviceType: ws.serviceType || '',
|
||||||
newStatus: ws.newStatus || '',
|
newStatus: ws.newStatus || '',
|
||||||
totalTime: ws.totalTime || '',
|
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 || '',
|
date: ws.endDate || ws.startDate || '',
|
||||||
details: ws.details || '',
|
details: ws.details || '',
|
||||||
isGit: ws.serviceType === 'GIT',
|
isGit: ws.serviceType === 'GIT',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* Admin-Ansichten nutzen weiter die Rohdaten.
|
* Admin-Ansichten nutzen weiter die Rohdaten.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { parseSupporters, supportMinutes, firstName } from './worksheetMinutes.js'
|
||||||
|
|
||||||
const GENERIC_UPDATE = 'Website aktualisiert'
|
const GENERIC_UPDATE = 'Website aktualisiert'
|
||||||
|
|
||||||
/* --------------------------------------------------------------- Commits --- */
|
/* --------------------------------------------------------------- Commits --- */
|
||||||
@@ -204,15 +206,20 @@ function customerWorkEntry(sheet, { includeUpdates }) {
|
|||||||
const [kind, label] = WORK_KINDS[type] || OTHER_WORK
|
const [kind, label] = WORK_KINDS[type] || OTHER_WORK
|
||||||
const date = isoDay(sheet.date || sheet.endDate || sheet.startDate)
|
const date = isoDay(sheet.date || sheet.endDate || sheet.startDate)
|
||||||
const minutes = Number(sheet.totalTime) || 0
|
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) {
|
if (isGit) {
|
||||||
// In den Projektdetails stehen die Aenderungen schon unter "Letzte Änderungen";
|
// In den Projektdetails stehen die Aenderungen schon unter "Letzte Änderungen";
|
||||||
// dort zaehlt ein Push nur, wenn Arbeitszeit nachgetragen wurde
|
// dort zaehlt ein Push nur, wenn Arbeitszeit nachgetragen wurde
|
||||||
if (!includeUpdates && !minutes) return null
|
if (!includeUpdates && !minutes) return null
|
||||||
const changes = changesFromPushDetails(sheet.details)
|
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. */
|
/** 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) {
|
if (entry.kind === 'update' && prev?.kind === 'update' && prev.date === entry.date) {
|
||||||
prev.minutes += entry.minutes
|
prev.minutes += entry.minutes
|
||||||
prev.changes.push(...entry.changes.filter((change) => !prev.changes.includes(change)))
|
prev.changes.push(...entry.changes.filter((change) => !prev.changes.includes(change)))
|
||||||
|
if (entry.supports) prev.supports = [...(prev.supports || []), ...entry.supports]
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
entries.push(entry)
|
entries.push(entry)
|
||||||
}
|
}
|
||||||
return entries
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isInternalTicket,
|
isInternalTicket,
|
||||||
customerOrder,
|
customerOrder,
|
||||||
customerWorkEntries,
|
customerWorkEntries,
|
||||||
|
workTotalMinutes,
|
||||||
} from '../server/services/customerProjectView.js'
|
} from '../server/services/customerProjectView.js'
|
||||||
|
|
||||||
// Commit wie listRepoCommits() ihn liefert (giteaAdmin.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' },
|
{ 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)
|
||||||
|
})
|
||||||
@@ -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 }])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user