// Attachment actions for R_Request tickets — ZIP download, server-side // printing (label printer / DIN A4), the 2× DIN A5 split-&-rotate print for // misformatted FBA label PDFs, the per-file "Erledigt" flag and delete. // // Extracted verbatim from components/requests/RequestForm.vue so the SAME // behaviour drives both ticket surfaces: the edit form (RequestForm — chat // bubbles + Anhänge card) and the my-tickets split view (MyTicketDetail). // The card markup lives in components/requests/RequestAttachmentsCard.vue. // // `attachment` objects are the ones RequestForm builds from the Strapi files: // { fileId, processed, name, ext, mime, size, width, height, url }. import { date } from 'alga-js' import { toast } from 'bulma-toast' import { analyzeLabelPdf, splitRotateA4LabelPdf, pdfBytesToBase64 } from '~/utils/labelPdfTools' export const isImageExt = (ext: string) => ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp', '.svg'].includes((ext || '').toLowerCase()) export const isPdfExt = (ext: string) => (ext || '').toLowerCase() === '.pdf' export const isVideoExt = (ext: string) => ['.mp4', '.m4v', '.avi', '.mpeg', '.webm', '.mov'].includes((ext || '').toLowerCase()) export const isAudioExt = (ext: string) => ['.mp3', '.wav', '.ogg', '.m4a', '.webm'].includes((ext || '').toLowerCase()) export const formatFileSize = (bytes: number) => { if (!bytes) return '' const kb = bytes / 1024 if (kb < 1024) return kb.toFixed(1) + ' KB' return (kb / 1024).toFixed(2) + ' MB' } // Label-printer picker for attachment printing — same source as the // commissioning page: CUST_CommissionTable rows (Settings → Commission Tables) // via the cached lookup, falling back to the built-in Tisch 1/2/3 list when the // table isn't seeded. Always starts on the default label printer // ('labelprinter-1'); other printers are a per-session choice and deliberately // NOT persisted (and NOT shared with the commissioning page's remembered Tisch). // Held in useState so the Anhänge card's picker and the chat-bubble print // buttons (two composable instances on the edit page) always agree. const DEFAULT_LABEL_PRINTER = 'labelprinter-1' const DEFAULT_PRINTER_OPTIONS = [ { value: 'labelprinter-1', label: 'Tisch 1' }, { value: 'labelprinter-4', label: 'Tisch 2' }, { value: 'labelprinter-5', label: 'Tisch 3' } ] export interface RequestAttachmentActionsOptions { requestId: () => string | number | null | undefined documentNo?: () => string | null | undefined strapiAttachmentDocId: () => string attachments: () => any[] isStaffRole: () => boolean /** Called after a file was deleted server-side so the owner can drop it from its own list. */ onDeleted?: (attachment: any) => void } export const useRequestAttachmentActions = (opts: RequestAttachmentActionsOptions) => { // Not awaited on purpose: keeps the calling component's setup synchronous. const { data: commissionTablesData } = useLookup('/api/commission/tables') const printerOptions = computed(() => { const rows = (commissionTablesData.value as any)?.records || [] return rows.length ? rows.map((t: any) => ({ value: t.shippingPrinter, label: t.name || ('Tisch ' + (t.number ?? '')) })) : DEFAULT_PRINTER_OPTIONS }) const labelPrinter = useState('requestAttachmentLabelPrinter', () => DEFAULT_LABEL_PRINTER) // If the default printer isn't among the configured tables (e.g. renamed in // Settings), snap to the first available option so a valid printer is selected. watch(printerOptions, (options) => { if (options.length && !options.some((o: any) => o.value === labelPrinter.value)) { labelPrinter.value = options[0].value } }, { immediate: true }) const printerLabelFor = (value: string) => printerOptions.value.find((o: any) => o.value === value)?.label || value const requestTag = () => `REQUEST-${opts.requestId() || ''}` // --- Bulk ZIP download ----------------------------------------------------- const downloadingAll = ref(false) const downloadAllAttachments = async () => { const docId = opts.strapiAttachmentDocId() if (!docId || downloadingAll.value) return downloadingAll.value = true try { const zipName = `Ticket-${opts.documentNo?.() || opts.requestId() || 'request'}-Anhaenge.zip`.replace(/[^\w.\-]+/g, '_') const blob: any = await $fetch(`/api/attachments/R_Request/${docId}/download-all`, { responseType: 'blob', query: { name: zipName }, headers: useRequestHeaders(['cookie']) }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = zipName document.body.appendChild(a) a.click() a.remove() setTimeout(() => URL.revokeObjectURL(url), 10000) } catch (err) { console.error('Download all attachments failed:', err) toast({ message: 'Anhänge konnten nicht heruntergeladen werden', type: 'is-danger', duration: 2500 }) } finally { downloadingAll.value = false } } // --- Print a single attachment server-side (staff only) -------------------- // Same flow as AttachmentModal: fetch the file, base64 it, POST to // /api/print/attachment-labels (label printer) or /api/print/ (DIN-A4 default printer). const printingAtt = ref>({}) const printAttachment = async (attachment: any, type: 'label' | 'normal') => { const key = attachment.url + '|' + type if (printingAtt.value[key]) return printingAtt.value[key] = true try { const response = await fetch(attachment.url) if (!response.ok) throw new Error('Attachment download failed') const blob = await response.blob() const fileContent = await new Promise((resolve, reject) => { const reader = new FileReader() reader.onloadend = () => resolve(String(reader.result).split(',')[1]) reader.onerror = () => reject(reader.error) reader.readAsDataURL(blob) }) // fileName lands unquoted in an `lp` shell command server-side — keep it shell-safe const safeName = String(attachment.name || 'attachment').replace(/[^\w.\-]+/g, '_') const res: any = await $fetch(type === 'label' ? '/api/print/attachment-labels' : '/api/print/', { method: 'POST', body: { pathName: `attachments/${new Date().getFullYear()}`, fileName: `${date.now().replace(/\-|\:/g, '_').replace(' ', '-')}-${requestTag()}-${safeName}`, fileContent, ...(type === 'label' ? { shippingPrinter: labelPrinter.value } : {}) } }) if (res?.stdout) { toast({ message: type === 'label' ? `Anhang wird auf dem Labeldrucker (${printerLabelFor(labelPrinter.value)}) gedruckt` : 'Anhang wird auf dem DIN-A4-Drucker gedruckt', type: 'is-success', duration: 2000 }) } else { toast({ message: 'Drucken fehlgeschlagen' + (res?.stderr ? ': ' + res.stderr : ''), type: 'is-danger', duration: 2500 }) } } catch (err) { console.error('Print attachment failed:', err) toast({ message: 'Drucken fehlgeschlagen', type: 'is-danger', duration: 2500 }) } finally { printingAtt.value[key] = false } } // --- Misformatted FBA label PDFs ------------------------------------------ // ONE A4-portrait page carrying TWO labels lying sideways (90° CCW) on it. // detectSplitCandidates() inspects every PDF attachment (page size + // rotated-content heuristic, see utils/labelPdfTools); printAttachmentSplit() // cuts such a file into 2× DIN A5, rotates each half 90° right and sends the // result to the label printer. The normal Label / DIN-A4 buttons stay untouched. const attPdfInfo = ref>({}) const splitInfoFor = (a: any) => attPdfInfo.value[a?.url] || ({} as any) const detectSplitCandidates = async () => { if (!opts.isStaffRole()) return const pdfs = opts.attachments() .filter(a => a && isPdfExt(a.ext) && !(a.url in attPdfInfo.value) && (!a.size || a.size < 20 * 1024 * 1024)) for (const a of pdfs) { attPdfInfo.value[a.url] = { checked: false, splitCandidate: false, sideways: false } try { const resp = await fetch(a.url) if (!resp.ok) continue const info = await analyzeLabelPdf(await resp.arrayBuffer()) attPdfInfo.value[a.url] = { checked: true, splitCandidate: info.splitCandidate, sideways: info.sidewaysDetected } } catch (e) { attPdfInfo.value[a.url] = { checked: true, splitCandidate: false, sideways: false } } } } const printAttachmentSplit = async (attachment: any) => { const key = attachment.url + '|split' if (printingAtt.value[key]) return printingAtt.value[key] = true try { const response = await fetch(attachment.url) if (!response.ok) throw new Error('Attachment download failed') const splitBytes = await splitRotateA4LabelPdf(await response.arrayBuffer()) const fileContent = pdfBytesToBase64(splitBytes) // fileName lands unquoted in an `lp` shell command server-side — keep it shell-safe const safeName = String(attachment.name || 'attachment').replace(/\.pdf$/i, '').replace(/[^\w.\-]+/g, '_') const res: any = await $fetch('/api/print/attachment-labels', { method: 'POST', body: { pathName: `attachments/${new Date().getFullYear()}`, fileName: `${date.now().replace(/\-|\:/g, '_').replace(' ', '-')}-${requestTag()}-${safeName}-SPLIT-A5.pdf`, fileContent, shippingPrinter: labelPrinter.value } }) if (res?.stdout) { toast({ message: `Anhang wird geteilt (2× DIN A5, 90° gedreht) auf dem Labeldrucker (${printerLabelFor(labelPrinter.value)}) gedruckt`, type: 'is-success', duration: 2000 }) } else { toast({ message: 'Drucken fehlgeschlagen' + (res?.stderr ? ': ' + res.stderr : ''), type: 'is-danger', duration: 2500 }) } } catch (err) { console.error('Split print attachment failed:', err) toast({ message: 'Drucken fehlgeschlagen', type: 'is-danger', duration: 2500 }) } finally { printingAtt.value[key] = false } } // --- Per-file "Erledigt" flag --------------------------------------------- // Persisted in the Strapi ad-attachment record's Processed_Files json column // (array of file ids); greys out the row in the Anhänge card. Optimistic // toggle with revert on failure. const togglingAtt = ref>({}) const toggleAttachmentProcessed = async (attachment: any) => { const docId = opts.strapiAttachmentDocId() if (!attachment.fileId || !docId || togglingAtt.value[attachment.fileId]) return togglingAtt.value[attachment.fileId] = true const newVal = !attachment.processed attachment.processed = newVal try { const res: any = await $fetch(`/api/attachments/R_Request/${docId}/processed`, { method: 'PUT', headers: useRequestHeaders(['cookie']), body: { fileId: attachment.fileId, processed: newVal } }) if (Number(res?.status) !== 200) throw new Error(res?.message || 'processed toggle failed') } catch (err) { console.error('Toggle attachment processed failed:', err) attachment.processed = !newVal toast({ message: 'Status konnte nicht gespeichert werden', type: 'is-danger', duration: 2500 }) } finally { togglingAtt.value[attachment.fileId] = false } } // --- Delete a single attachment file (Strapi upload file) after confirmation const deletingAtt = ref>({}) const deleteAttachment = async (attachment: any) => { if (!attachment.fileId || deletingAtt.value[attachment.fileId]) return if (!window.confirm(`Anhang "${attachment.name}" wirklich löschen?`)) return deletingAtt.value[attachment.fileId] = true try { const res: any = await $fetch(`/api/attachments/R_Request/${attachment.fileId}`, { method: 'DELETE', headers: useRequestHeaders(['cookie']) }) // the route returns {} on success and { status, message } on failure if (res?.status && Number(res.status) >= 400) throw new Error(res?.message || 'delete failed') opts.onDeleted?.(attachment) toast({ message: 'Anhang gelöscht', type: 'is-success', duration: 2000 }) } catch (err) { console.error('Delete attachment failed:', err) toast({ message: 'Anhang konnte nicht gelöscht werden', type: 'is-danger', duration: 2500 }) } finally { deletingAtt.value[attachment.fileId] = false } } return { printerOptions, labelPrinter, printerLabelFor, downloadingAll, downloadAllAttachments, printingAtt, printAttachment, printAttachmentSplit, attPdfInfo, splitInfoFor, detectSplitCandidates, togglingAtt, toggleAttachmentProcessed, deletingAtt, deleteAttachment } }