import { loadPdfMake } from '~/utils/loadPdfMake' /** * Merchant-facing ticket documentation PDF (German). * * Purpose: merchants cannot see tickets that are org-assigned to LogYou — this * report is what support hands them on request. Contents: * 1. Header: general ticket information * 2. "Zeiterfassung & Kosten": ALL time-billing entries of the ticket grouped * into one table (parsed from the [Zeitabrechnung] request-updates + * QtySpent minutes), with totals reconciled against the ticket totals * 3. "Verlauf": every step/message chronologically, grouped by day * (request-updates + request-history; the changelog stream is skipped on * purpose — it is unreliable for R_Request and internal noise) * * Data is fetched here (request-update + request-history); resolved header * fields come from the edit page's form (identifiers already readable). */ interface ReportHeader { documentNo: string summary: string partner: string organization: string contact: string salesRep: string requestType: string category: string status: string resolution: string priority: string created: string dateLastAction: string startDate: string endDate: string qtySpentTotal: number requestAmtTotal: number } const parseGermanAmount = (raw: string): number | null => { const cleaned = String(raw).replace(/[^\d,.-]/g, '') if (!cleaned) return null const normalized = cleaned.replace(/\./g, '').replace(',', '.') const value = Number(normalized) return isNaN(value) ? null : value } const formatEur = (value: number | null | undefined): string => { if (value === null || value === undefined || isNaN(value)) return '' return Number(value).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €' } const formatMinutes = (minutes: number): string => { const m = Math.max(0, Math.round(minutes)) const h = Math.floor(m / 60) const rest = m % 60 return h > 0 ? `${h}h ${rest}min` : `${rest}min` } const formatDateTime = (val: any): string => { if (!val) return '' const d = new Date(val) if (isNaN(d.getTime())) return '' return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) } const formatDay = (val: any): string => { if (!val) return '' const d = new Date(val) if (isNaN(d.getTime())) return '' return d.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: '2-digit', year: 'numeric' }) } const formatTime = (val: any): string => { if (!val) return '' const d = new Date(val) if (isNaN(d.getTime())) return '' return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) } // Rewrite ISO timestamps inside [START:...]/[END:...] markers into readable form const humanizeMarkers = (text: string): string => { return String(text).replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:?\d{2}|Z)?/g, (iso) => { const d = new Date(iso) return isNaN(d.getTime()) ? iso : d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) }) } /** Parse one [Zeitabrechnung] block for rate / amount / note. * "Direktbetrag" = an amount booked without time (may be negative — a credit), * so the amount capture must keep a leading minus. */ const parseZeitabrechnung = (text: string) => { const rateMatch = text.match(/Stundensatz:\s*([\d.,]+)/) const amountMatch = text.match(/(?:Direktbetrag|Berechneter Betrag|Neuer Gesamtbetrag):\s*(-?\s?[\d.,]+)/) const timeMatch = text.match(/Zeit erfasst:\s*([^\n]+)/) const noteMatch = text.match(/Notiz:\s*\n?([\s\S]*)$/) return { rate: rateMatch ? parseGermanAmount(rateMatch[1]) : null, amount: amountMatch ? parseGermanAmount(amountMatch[1]) : null, timeText: timeMatch ? timeMatch[1].trim() : '', note: noteMatch ? noteMatch[1].trim().substring(0, 200) : '' } } export const useRequestReportPdf = () => { const buildRequestReportPdf = async (requestId: string | number, header: ReportHeader) => { // ---- Fetch the two trustworthy streams -------------------------------- const messages: any[] = [] const timeEntries: any[] = [] try { const updRes: any = await $fetch(`/api/requests/requests/${requestId}/request-update`, { headers: useRequestHeaders(['cookie']) }) for (const item of (updRes?.records || [])) { const text = (item.Result || item.Summary || '').toString().trim() const qtySpent = Number(item.QtySpent || 0) const date = item.Created || item.Updated || '' const author = item.CreatedBy?.identifier || item.UpdatedBy?.identifier || 'System' const isBillingBlock = /\[Zeitabrechnung\]/.test(text) // Staff-only notes (ConfidentialTypeEntry='I') must never reach this // merchant-facing report — same rule the live chat UI enforces. The // billing block is the one deliberate exception: it's Internal too, // but this report's whole point is disclosing the cost breakdown to // the merchant on request (see "Zeiterfassung & Kosten" below). const isInternalNote = item.ConfidentialTypeEntry?.id === 'I' && !isBillingBlock if (text && !isInternalNote) { messages.push({ text, author, date, qtySpent }) } if (qtySpent > 0 || isBillingBlock) { const parsed = parseZeitabrechnung(text) timeEntries.push({ date, author, minutes: qtySpent, timeText: parsed.timeText || (qtySpent > 0 ? formatMinutes(qtySpent) : ''), rate: parsed.rate, amount: parsed.amount, note: parsed.note }) } } } catch (e) { /* stream unavailable -> report still renders */ } try { const histRes: any = await $fetch(`/api/requests/requests/${requestId}/request-history`, { headers: useRequestHeaders(['cookie']) }) const sorted = [...(histRes?.records || [])].sort((a: any, b: any) => new Date(a.Created || a.Updated || 0).getTime() - new Date(b.Created || b.Updated || 0).getTime() ) // Same prefix-diff as the internal export: history rows carry the FULL // summary each time; only the newly added tail is a real step. let prevSummary = '' for (const item of sorted) { const fullSummary = (item.Summary || '').toString() let text = fullSummary if (prevSummary && fullSummary.startsWith(prevSummary)) { const added = fullSummary.slice(prevSummary.length).replace(/^\n+/, '').trim() text = added || '' } if (text.trim()) { messages.push({ text: text.trim(), author: item.CreatedBy?.identifier || item.UpdatedBy?.identifier || 'System', date: item.Created || item.Updated || '', qtySpent: 0 }) } if (fullSummary.trim()) prevSummary = fullSummary } } catch (e) { /* skip */ } messages.sort((a, b) => (new Date(a.date || 0).getTime() || 0) - (new Date(b.date || 0).getTime() || 0)) timeEntries.sort((a, b) => (new Date(a.date || 0).getTime() || 0) - (new Date(b.date || 0).getTime() || 0)) // ---- Header info table ------------------------------------------------ const infoPairs: Array<[string, string]> = [ ['Ticket-Nr.', header.documentNo], ['Betreff', humanizeMarkers(header.summary || '').substring(0, 500)], ['Partner', header.partner], ['Organisation', header.organization], ['Kontakt', header.contact], ['Bearbeiter', header.salesRep], ['Typ', header.requestType], ['Kategorie', header.category], ['Status', header.status], ['Lösung', header.resolution], ['Priorität', header.priority], ['Erstellt', formatDateTime(header.created)], ['Letzte Aktion', formatDateTime(header.dateLastAction)], ['Startdatum', formatDateTime(header.startDate)], ['Enddatum', formatDateTime(header.endDate)] ] const infoRows = infoPairs .filter(([, v]) => v && String(v).trim() !== '' && String(v).trim() !== '-') .map(([label, value]) => ([ { text: label, style: 'fieldLabel' }, { text: String(value), style: 'fieldValue' } ])) // ---- Time/cost table -------------------------------------------------- const sumMinutes = timeEntries.reduce((acc, e) => acc + (e.minutes || 0), 0) const sumAmount = timeEntries.reduce((acc, e) => acc + (e.amount || 0), 0) const costBody: any[] = [[ { text: 'Datum', style: 'th' }, { text: 'Dauer', style: 'th' }, { text: 'Stundensatz', style: 'th', alignment: 'right' }, { text: 'Betrag', style: 'th', alignment: 'right' }, { text: 'Notiz', style: 'th' } ]] for (const entry of timeEntries) { costBody.push([ { text: formatDateTime(entry.date), fontSize: 8 }, { text: entry.timeText || (entry.minutes ? formatMinutes(entry.minutes) : ''), fontSize: 8 }, { text: entry.rate !== null && entry.rate !== undefined ? formatEur(entry.rate) + '/h' : '', fontSize: 8, alignment: 'right' }, { text: formatEur(entry.amount), fontSize: 8, alignment: 'right' }, { text: entry.note || '', fontSize: 8 } ]) } if (timeEntries.length) { costBody.push([ { text: 'Summe Einträge', bold: true, fontSize: 8 }, { text: formatMinutes(sumMinutes), bold: true, fontSize: 8 }, { text: '', fontSize: 8 }, { text: sumAmount > 0 ? formatEur(sumAmount) : '', bold: true, fontSize: 8, alignment: 'right' }, { text: '', fontSize: 8 } ]) } costBody.push([ { text: 'Gesamt laut Ticket', bold: true, fontSize: 8, fillColor: '#eef2ff' }, { text: formatMinutes(header.qtySpentTotal || 0), bold: true, fontSize: 8, fillColor: '#eef2ff' }, { text: '', fillColor: '#eef2ff' }, { text: formatEur(header.requestAmtTotal || 0), bold: true, fontSize: 8, alignment: 'right', fillColor: '#eef2ff' }, { text: '', fillColor: '#eef2ff' } ]) // ---- Timeline grouped by day ------------------------------------------ const timelineContent: any[] = [] let currentDay = '' for (const message of messages) { const day = formatDay(message.date) if (day && day !== currentDay) { currentDay = day timelineContent.push({ text: day, style: 'dayHeader', margin: [0, 12, 0, 4] }) } timelineContent.push({ margin: [0, 2, 0, 6], table: { widths: ['*'], body: [[{ stack: [ { text: `${formatTime(message.date)} — ${message.author}` + (message.qtySpent > 0 ? ` · ⏱ ${formatMinutes(message.qtySpent)}` : ''), style: 'msgMeta' }, { text: humanizeMarkers(message.text), style: 'msgText' } ], fillColor: '#fafafa' }]] }, layout: { hLineWidth: () => 0.5, vLineWidth: () => 0.5, hLineColor: () => '#e5e7eb', vLineColor: () => '#e5e7eb', paddingLeft: () => 8, paddingRight: () => 8, paddingTop: () => 5, paddingBottom: () => 5 } }) } // ---- Document --------------------------------------------------------- const now = new Date() const docDefinition: any = { pageSize: 'A4', pageMargins: [40, 50, 40, 60], footer: (currentPage: number, pageCount: number) => ({ columns: [ { text: `Ticket ${header.documentNo} — erstellt am ${now.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}`, fontSize: 7, color: '#9ca3af', margin: [40, 0, 0, 0] }, { text: `Seite ${currentPage} von ${pageCount}`, fontSize: 7, color: '#9ca3af', alignment: 'right', margin: [0, 0, 40, 0] } ], margin: [0, 20, 0, 0] }), content: [ { text: 'Ticket-Dokumentation', fontSize: 18, bold: true, margin: [0, 0, 0, 2] }, { text: `${header.documentNo}${header.partner ? ' · ' + header.partner : ''}`, fontSize: 11, color: '#6b7280', margin: [0, 0, 0, 16] }, { text: 'Allgemeine Informationen', style: 'sectionHeader', margin: [0, 0, 0, 8] }, { table: { widths: [130, '*'], body: infoRows }, layout: { fillColor: (rowIndex: number) => rowIndex % 2 === 0 ? '#fafafa' : null, hLineWidth: () => 0.5, vLineWidth: () => 0.5, hLineColor: () => '#e5e7eb', vLineColor: () => '#e5e7eb', paddingLeft: () => 8, paddingRight: () => 8, paddingTop: () => 5, paddingBottom: () => 5 }, margin: [0, 0, 0, 20] }, { text: 'Zeiterfassung & Kosten', style: 'sectionHeader', margin: [0, 0, 0, 8] }, ...(timeEntries.length === 0 && !(header.qtySpentTotal > 0 || Number(header.requestAmtTotal || 0) !== 0) ? [{ text: 'Keine Zeit-/Kosteneinträge vorhanden.', fontSize: 9, italics: true, color: '#6b7280', margin: [0, 0, 0, 20] }] : [{ table: { headerRows: 1, widths: [80, 60, 70, 70, '*'], body: costBody }, layout: { hLineWidth: () => 0.5, vLineWidth: () => 0.5, hLineColor: () => '#e5e7eb', vLineColor: () => '#e5e7eb', paddingLeft: () => 6, paddingRight: () => 6, paddingTop: () => 4, paddingBottom: () => 4 }, margin: [0, 0, 0, 20] }]), { text: 'Verlauf', style: 'sectionHeader', margin: [0, 0, 0, 4] }, ...(timelineContent.length ? timelineContent : [{ text: 'Keine Einträge vorhanden.', fontSize: 9, italics: true, color: '#6b7280' }]) ], styles: { sectionHeader: { fontSize: 12, bold: true, color: '#111827' }, fieldLabel: { fontSize: 8.5, bold: true, color: '#374151' }, fieldValue: { fontSize: 8.5 }, th: { bold: true, fontSize: 8, fillColor: '#f0f0f0' }, dayHeader: { fontSize: 9.5, bold: true, color: '#4b5563' }, msgMeta: { fontSize: 7.5, color: '#6b7280', margin: [0, 0, 0, 2] }, msgText: { fontSize: 8.5, lineHeight: 1.25 } }, defaultStyle: { fontSize: 9 } } const pdfMake = await loadPdfMake() return pdfMake.createPdf(docDefinition) } return { buildRequestReportPdf } }