diff --git a/src/lib/worksheetFlow.js b/src/lib/worksheetFlow.js new file mode 100644 index 0000000..f4e29f4 --- /dev/null +++ b/src/lib/worksheetFlow.js @@ -0,0 +1,32 @@ +/* Folgen eines neuen Arbeitsblatts (bisher TicketCard.handleCreateWorksheet). + Kennt kein Appwrite: die Seite reicht die Schreibzugriffe herein. */ + +// Akquise-Automatik: das Ergebnis im Arbeitsblatt steuert den Kundenstatus. +// "customer" = Zugesagt, "lost" = Abgesagt, sonst keine Aenderung. +export function acquisitionOutcome(ticket, newStatus) { + if (ticket?.type !== 'Akquise' || !ticket.customerId) return null + if (newStatus === 'Zugesagt') return 'customer' + if (newStatus === 'Abgesagt') return 'lost' + return null +} + +/* 1. Arbeitsblatt anlegen. 2. Bei Erfolg den Ticket-Status nachziehen, wenn er abweicht. + 3. Bei Akquise-Tickets den Kundenstatus setzen (Fehler werden verschluckt). + Rueckgabe ist das Ergebnis von createWorksheet. */ +export async function createWorksheetFlow({ ticket, worksheetData, user, createWorksheet, updateTicket, updateCustomer, now = () => new Date() }) { + const result = await createWorksheet(worksheetData, user) + if (!result?.success) return result + + const newStatus = worksheetData?.newStatus + if (newStatus !== ticket.status) { + await updateTicket(ticket.$id, { status: newStatus }) + } + + const customerStatus = acquisitionOutcome(ticket, newStatus) + if (customerStatus) { + try { + await updateCustomer(ticket.customerId, { customerStatus, updatedAt: now().toISOString() }) + } catch { /* nicht kritisch */ } + } + return result +} diff --git a/src/lib/worksheetView.js b/src/lib/worksheetView.js new file mode 100644 index 0000000..b47938f --- /dev/null +++ b/src/lib/worksheetView.js @@ -0,0 +1,75 @@ +/* Anzeige- und Rechenlogik der Arbeitsblatt-Zeilen und der Kennzahlen darueber. + Kommentare haben keine Arbeitszeit und zaehlen nie mit. Die Zeit in der Zeile ist die + gespeicherte Arbeitszeit (totalTime); die Kennzahlen rechnen die Unterstuetzung mit. */ + +import { worksheetMinutes, supportMinutes, parseSupporters, firstName } from './support' +import { needsTimeEntry, worksheetTone, isGitWorksheet, serviceTypeLabel } from './ticketForm' +import { statusLabel } from './ticketStatus' +import { stepLabel } from './timeSteps' +import { displayDate, displayTime } from './ticketView' + +// Summe der Arbeitszeit inklusive Unterstuetzung ueber alle Bloecke ohne Kommentare, optional gefiltert. +// Dieselbe Funktion rechnet die Spalte "Zeit" der Ticketliste. +export function sumWorksheetMinutes(worksheets, predicate = () => true) { + return (worksheets || []) + .filter((ws) => !ws.isComment && predicate(ws)) + .reduce((sum, ws) => sum + worksheetMinutes(ws), 0) +} + +// Kennzahlen ueber der Liste: Arbeitszeit gesamt, davon Abo-Stunden, Anzahl offener Git-Zeiten +export function worksheetSums(worksheets) { + const list = worksheets || [] + return { + totalMinutes: sumWorksheetMinutes(list), + aboMinutes: sumWorksheetMinutes(list, (ws) => Boolean(ws.countsToPlan)), + openCount: list.filter(needsTimeEntry).length, + } +} + +// Regel fuer den Abo-Schalter einer Zeile; ob die Seite ihn ueberhaupt anbietet (onTogglePlan), prueft die Komponente +export function showAboToggle(ws, aboHoursAllowed) { + return Boolean(!ws.isComment && Number(ws.totalTime) > 0 && (aboHoursAllowed || ws.countsToPlan)) +} + +// Wann: Startzweig, wenn ein Startdatum gesetzt ist, sonst der Endzweig. Altwerte bleiben Rohtext. +export function worksheetWhen(ws) { + const start = String(ws?.startDate ?? '').trim() !== '' + return start + ? { date: displayDate(ws.startDate), time: displayTime(ws.startTime) } + : { date: displayDate(ws?.endDate), time: displayTime(ws?.endTime) } +} + +export function worksheetKindLabel(ws) { + if (ws?.isComment && !isGitWorksheet(ws)) return 'Kommentar' + return serviceTypeLabel(ws?.serviceType) || '' +} + +// Git-Pushes: orange, bis die Zeit nachgetragen ist, danach gruen; sonst kein Abzeichen +export function worksheetBadge(ws) { + const tone = worksheetTone(ws) + if (tone === 'git-open') return { tone: 'orange', text: 'Zeit offen' } + if (tone === 'git-done') return { tone: 'green', text: 'Zeit eingetragen' } + return null +} + +// Nur wenn sich der Status geaendert hat +export function statusAfterLabel(ws) { + const next = ws?.newStatus + if (!next || next === ws.oldStatus) return '' + return `Status danach: ${statusLabel(next)}` +} + +// Je unterstuetzender Person eine Zeile, z. B. "Unterstützung Anna · DNS · 30 min" +export function supportLines(ws) { + if (!ws || ws.isComment) return [] + const minutes = stepLabel(supportMinutes(ws.totalTime)) + return parseSupporters(ws.supporters).map((sup) => + `${['Unterstützung', firstName(sup.name)].filter(Boolean).join(' ')} · ${sup.task} · ${minutes}`) +} + +// Zeit rechts in der Zeile: Kommentare leer, ohne Arbeitszeit ein Strich +export function timeLabel(ws) { + if (ws?.isComment) return '' + const minutes = Number(ws?.totalTime) || 0 + return minutes <= 0 ? '–' : stepLabel(minutes) +} diff --git a/tests/unit/worksheetFlow.test.mjs b/tests/unit/worksheetFlow.test.mjs new file mode 100644 index 0000000..7d95168 --- /dev/null +++ b/tests/unit/worksheetFlow.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { createWorksheetFlow, acquisitionOutcome } = await loadModule('src/lib/worksheetFlow.js') +async function run(ticket, newStatus, over = {}) { + const calls = { ticket: [], customer: [] } + const result = await createWorksheetFlow({ + ticket, worksheetData: { newStatus }, user: { $id: 'u' }, + createWorksheet: async () => over.result ?? { success: true }, + updateTicket: async (id, data) => { calls.ticket.push([id, data]) }, + updateCustomer: async (id, data) => { calls.customer.push([id, data]); if (over.customerFails) throw new Error('weg') }, + now: () => new Date('2026-10-05T10:00:00Z'), + }) + return { result, calls } +} +const akq = { $id: 't1', type: 'Akquise', status: 'In Kontakt', customerId: 'c1' } +test('Akquise + Zugesagt: Status zurückschreiben, Kunde fest', async () => { + const { calls } = await run(akq, 'Zugesagt') + assert.deepEqual(calls.ticket, [['t1', { status: 'Zugesagt' }]]) + assert.deepEqual(calls.customer, [['c1', { customerStatus: 'customer', updatedAt: '2026-10-05T10:00:00.000Z' }]]) +}) +test('Akquise + Abgesagt: Kunde verloren', async () => { + assert.deepEqual((await run(akq, 'Abgesagt')).calls.customer, [['c1', { customerStatus: 'lost', updatedAt: '2026-10-05T10:00:00.000Z' }]]) +}) +test('Akquise + In Kontakt: keine Änderung', async () => { + assert.deepEqual((await run(akq, 'In Kontakt')).calls, { ticket: [], customer: [] }) + assert.deepEqual((await run({ ...akq, status: 'Zugesagt' }, 'In Kontakt')).calls.customer, []) +}) +test('Nicht-Akquise ändert nie den Kunden, Status nur bei Abweichung', async () => { + for (const status of ['Zugesagt', 'Abgesagt', 'Closed']) assert.deepEqual((await run({ ...akq, type: 'Webpage', status: 'Open' }, status)).calls.customer, []) + assert.deepEqual((await run({ ...akq, type: 'Webpage', status: 'Closed' }, 'Closed')).calls.ticket, []) + assert.deepEqual((await run({ ...akq, customerId: '' }, 'Zugesagt')).calls.customer, []) +}) +test('Fehlschlag und Kundenfehler', async () => { + const failed = await run(akq, 'Zugesagt', { result: { success: false, error: 'x' } }) + assert.deepEqual(failed, { result: { success: false, error: 'x' }, calls: { ticket: [], customer: [] } }) + assert.equal((await run(akq, 'Zugesagt', { customerFails: true })).result.success, true) + assert.equal(acquisitionOutcome({ type: 'Akquise', customerId: 'c' }, 'Zugesagt'), 'customer') +}) diff --git a/tests/unit/worksheetView.test.mjs b/tests/unit/worksheetView.test.mjs new file mode 100644 index 0000000..97ad096 --- /dev/null +++ b/tests/unit/worksheetView.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const w = await loadModule('src/lib/worksheetView.js') +const ws = (o = {}) => ({ totalTime: 60, isComment: false, countsToPlan: false, serviceType: 'Remote', ...o }) +const sup = (n, name = 'Anna Meier') => JSON.stringify(Array.from({ length: n }, (_, i) => ({ employeeId: 'e' + i, name, task: 'DNS' }))) +test('worksheetSums: Kommentare zählen nie, Abo nur countsToPlan, Zeiten offen', () => { + const list = [ws(), ws({ supporters: sup(1), countsToPlan: true }), ws({ isComment: true, totalTime: 0 }), ws({ serviceType: 'GIT', isComment: true, totalTime: 0 }), ws({ serviceType: 'GIT', totalTime: 30, startTime: '0900' })] + assert.deepEqual(w.worksheetSums(list), { totalMinutes: 180, aboMinutes: 90, openCount: 1 }) + assert.deepEqual(w.worksheetSums([]), { totalMinutes: 0, aboMinutes: 0, openCount: 0 }) + assert.deepEqual(w.worksheetSums([ws({ isComment: true, countsToPlan: true })]), { totalMinutes: 0, aboMinutes: 0, openCount: 0 }) +}) +test('showAboToggle: Regel bleibt exakt', () => { + assert.equal(w.showAboToggle(ws(), true), true) + assert.equal(w.showAboToggle(ws(), false), false) + assert.equal(w.showAboToggle(ws({ countsToPlan: true }), false), true) + assert.equal(w.showAboToggle(ws({ totalTime: 0 }), true), false) + assert.equal(w.showAboToggle(ws({ isComment: true }), true), false) + assert.equal(w.showAboToggle(ws({ totalTime: '45' }), true), true) +}) +test('worksheetWhen: Start-, sonst Endzweig, Altwerte als Rohtext', () => { + assert.deepEqual(w.worksheetWhen({ startDate: '2.5.2026', startTime: '0930', endDate: '2.5.2026', endTime: '1100' }), { date: '02.05.2026', time: '09:30' }) + assert.deepEqual(w.worksheetWhen({ startDate: '', startTime: '', endDate: '31.02.2026', endTime: '1405' }), { date: '31.02.2026', time: '14:05' }) + assert.deepEqual(w.worksheetWhen({ startDate: '03.10.2026', startTime: '' }), { date: '03.10.2026', time: '' }) + assert.deepEqual(w.worksheetWhen({}), { date: '', time: '' }) +}) +test('Art, Badge, Status danach, Zeit', () => { + assert.equal(w.worksheetKindLabel(ws({ isComment: true })), 'Kommentar') + assert.deepEqual(['Remote', 'On Site', 'Off Site', 'GIT'].map((s) => w.worksheetKindLabel(ws({ serviceType: s, isComment: s === 'GIT' }))), ['Fernwartung', 'Vor Ort', 'Fernwartung', 'GIT']) + assert.deepEqual(w.worksheetBadge(ws({ serviceType: 'GIT', isComment: true, totalTime: 0 })), { tone: 'orange', text: 'Zeit offen' }) + assert.deepEqual(w.worksheetBadge(ws({ serviceType: 'GIT', startTime: '0900' })), { tone: 'green', text: 'Zeit eingetragen' }) + assert.equal(w.worksheetBadge(ws()), null) + assert.equal(w.statusAfterLabel({ oldStatus: 'Open', newStatus: 'Occupied' }), 'Status danach: In Arbeit') + assert.equal(w.statusAfterLabel({ oldStatus: 'Closed', newStatus: 'Open' }), 'Status danach: Offen') + assert.equal(w.statusAfterLabel({ oldStatus: 'Open', newStatus: 'Completed' }), 'Status danach: Completed') + for (const same of [{ oldStatus: 'Open', newStatus: 'Open' }, { oldStatus: 'Open', newStatus: '' }, {}]) assert.equal(w.statusAfterLabel(same), '') + assert.deepEqual([ws({ isComment: true }), ws({ totalTime: 0 }), ws({ totalTime: 90 }), ws({ totalTime: '45' })].map(w.timeLabel), ['', '–', '1 h 30 min', '45 min']) +}) +test('supportLines: je Person eine Zeile, Wörter wie bisher', () => { + assert.deepEqual(w.supportLines(ws({ supporters: sup(1) })), ['Unterstützung Anna · DNS · 30 min']) + assert.deepEqual(w.supportLines(ws({ totalTime: 75, supporters: sup(1, '') })), ['Unterstützung · DNS · 45 min']) + assert.deepEqual(w.supportLines(ws({ isComment: true, supporters: sup(1) })), []) + assert.deepEqual(w.supportLines(ws({ supporters: '{kaputt' })), []) +})