Bestellungen: Karten-Vorschau je Position vor dem Bestellen

- Vorschau-Button (👁) je Position im Bestell-Dialog (aktiv wenn
  Produkt+Mitarbeiter gewählt): rendert card.pdf via pdf.js (Vorder-/
  Rückseite) im Vorschau-Modal mit echten Mitarbeiterdaten × Produktlayout.
- Auch im Bestell-Detail je Position (Reseller-Prüfung vor Produktion).
- Modal: optionaler wide-Prop für Bestell-/Vorschau-Modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-03 13:45:17 +02:00
parent 7c3b06c996
commit bbe7c1b71c
2 changed files with 76 additions and 7 deletions

View File

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ title: string }>() defineProps<{ title: string; wide?: boolean }>()
const emit = defineEmits<{ close: [] }>() const emit = defineEmits<{ close: [] }>()
</script> </script>
<template> <template>
<div class="overlay" @click.self="emit('close')"> <div class="overlay" @click.self="emit('close')">
<div class="card modal"> <div class="card modal" :class="{ 'modal--wide': wide }">
<header class="modal__head"> <header class="modal__head">
<h3>{{ title }}</h3> <h3>{{ title }}</h3>
<button class="x" @click="emit('close')" aria-label="Schließen">×</button> <button class="x" @click="emit('close')" aria-label="Schließen">×</button>
@ -23,6 +23,7 @@ const emit = defineEmits<{ close: [] }>()
display: flex; align-items: center; justify-content: center; padding: 1rem; z-index: 50; display: flex; align-items: center; justify-content: center; padding: 1rem; z-index: 50;
} }
.modal { width: 100%; max-width: 480px; max-height: 90vh; overflow: auto; } .modal { width: 100%; max-width: 480px; max-height: 90vh; overflow: auto; }
.modal--wide { max-width: 680px; }
.modal__head { .modal__head {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 1.1rem 1.4rem; border-bottom: 1px solid var(--line); padding: 1.1rem 1.4rem; border-bottom: 1px solid var(--line);

View File

@ -4,6 +4,10 @@ import client from '@/api/client'
import { list } from '@/api/resources' import { list } from '@/api/resources'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import * as pdfjsLib from 'pdfjs-dist'
import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
interface OrderSummary { interface OrderSummary {
id: string; number: string; status: string id: string; number: string; status: string
@ -50,6 +54,9 @@ const form = ref<{ note: string; items: { product: string; employee: string; qua
const detail = ref<OrderDetail | null>(null) const detail = ref<OrderDetail | null>(null)
const busy = ref(false) const busy = ref(false)
// Vorschau der gerenderten Karte (Mitarbeiter × Produkt) vor dem Bestellen
const preview = ref<{ employee: string; product: string; loading: boolean; pages: string[] } | null>(null)
async function load() { async function load() {
loading.value = true loading.value = true
const { data } = await client.get<{ member: OrderSummary[] }>('/orders') const { data } = await client.get<{ member: OrderSummary[] }>('/orders')
@ -116,6 +123,40 @@ async function openPdf(item: OrderItem) {
window.open(URL.createObjectURL(res.data as Blob), '_blank') window.open(URL.createObjectURL(res.data as Blob), '_blank')
} }
// Rendert die druckfertige Karte (Vorder-/Rückseite) als Bild für die Vorschau.
async function renderPreview(employeeId: string, productId: string, employeeName: string, productName: string) {
preview.value = { employee: employeeName, product: productName, loading: true, pages: [] }
try {
const res = await client.get(`/employees/${employeeId}/card.pdf`, { responseType: 'arraybuffer', params: { product: productId } })
const doc = await pdfjsLib.getDocument({ data: new Uint8Array(res.data as ArrayBuffer) }).promise
const pages: string[] = []
for (let p = 1; p <= doc.numPages; p++) {
const page = await doc.getPage(p)
const base = page.getViewport({ scale: 1 })
const vp = page.getViewport({ scale: 300 / base.width })
const canvas = document.createElement('canvas')
canvas.width = vp.width
canvas.height = vp.height
await page.render({ canvas, canvasContext: canvas.getContext('2d')!, viewport: vp }).promise
pages.push(canvas.toDataURL('image/png'))
}
if (preview.value) { preview.value.pages = pages; preview.value.loading = false }
} catch {
if (preview.value) preview.value.loading = false
}
}
function previewRow(row: { product: string; employee: string }) {
if (!row.product || !row.employee) return
const prod = products.value.find((p) => p['@id'] === row.product)
const emp = employees.value.find((e) => e['@id'] === row.employee)
renderPreview(idOf(row.employee), idOf(row.product), emp ? `${emp.firstName} ${emp.lastName}` : '', prod?.name ?? '')
}
function previewDetailItem(it: OrderItem) {
renderPreview(it.employee.id, it.product.id, it.employee.name, it.product.name)
}
function fmtDate(s: string) { return new Date(s).toLocaleDateString('de-DE') } function fmtDate(s: string) { return new Date(s).toLocaleDateString('de-DE') }
onMounted(async () => { await load(); if (canOrder.value) await loadRefs() }) onMounted(async () => { await load(); if (canOrder.value) await loadRefs() })
@ -157,10 +198,10 @@ onMounted(async () => { await load(); if (canOrder.value) await loadRefs() })
</div> </div>
<!-- Neue Bestellung --> <!-- Neue Bestellung -->
<Modal v-if="showCreate" title="Neue Bestellung" @close="showCreate = false"> <Modal v-if="showCreate" title="Neue Bestellung" wide @close="showCreate = false">
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<div class="items"> <div class="items">
<div class="items-head"><span>Produkt</span><span>Mitarbeiter</span><span>Menge</span><span></span></div> <div class="items-head"><span>Produkt</span><span>Mitarbeiter</span><span>Menge</span><span></span><span></span></div>
<div v-for="(row, i) in form.items" :key="i" class="item-row"> <div v-for="(row, i) in form.items" :key="i" class="item-row">
<select class="input" v-model="row.product" required> <select class="input" v-model="row.product" required>
<option v-for="p in products" :key="p['@id']" :value="p['@id']">{{ p.name }}</option> <option v-for="p in products" :key="p['@id']" :value="p['@id']">{{ p.name }}</option>
@ -170,6 +211,7 @@ onMounted(async () => { await load(); if (canOrder.value) await loadRefs() })
<option v-for="e in employees" :key="e['@id']" :value="e['@id']">{{ e.firstName }} {{ e.lastName }}</option> <option v-for="e in employees" :key="e['@id']" :value="e['@id']">{{ e.firstName }} {{ e.lastName }}</option>
</select> </select>
<input class="input qty" type="number" min="1" v-model.number="row.quantity" /> <input class="input qty" type="number" min="1" v-model.number="row.quantity" />
<button type="button" class="btn btn-ghost btn-sm eye" :disabled="!row.product || !row.employee" @click="previewRow(row)" title="Vorschau">👁</button>
<button type="button" class="btn btn-ghost btn-sm del" :disabled="form.items.length === 1" @click="removeRow(i)" title="Position entfernen"></button> <button type="button" class="btn btn-ghost btn-sm del" :disabled="form.items.length === 1" @click="removeRow(i)" title="Position entfernen"></button>
</div> </div>
</div> </div>
@ -201,7 +243,10 @@ onMounted(async () => { await load(); if (canOrder.value) await loadRefs() })
<td><strong>{{ it.product.name }}</strong></td> <td><strong>{{ it.product.name }}</strong></td>
<td>{{ it.employee.name }}</td> <td>{{ it.employee.name }}</td>
<td>{{ it.quantity }}</td> <td>{{ it.quantity }}</td>
<td class="right"><button class="btn btn-ghost btn-sm" @click="openPdf(it)">PDF</button></td> <td class="right">
<button class="btn btn-ghost btn-sm" @click="previewDetailItem(it)">Vorschau</button>
<button class="btn btn-ghost btn-sm" @click="openPdf(it)">PDF</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -219,6 +264,20 @@ onMounted(async () => { await load(); if (canOrder.value) await loadRefs() })
</button> </button>
</div> </div>
</Modal> </Modal>
<!-- Vorschau der gerenderten Karte -->
<Modal v-if="preview" :title="`Vorschau · ${preview.employee}`" wide @close="preview = null">
<p class="muted prev-sub">{{ preview.product }}</p>
<div v-if="preview.loading" class="prev-loading">Vorschau wird gerendert</div>
<div v-else-if="!preview.pages.length" class="prev-loading">Keine Vorschau verfügbar.</div>
<div v-else class="prev-pages">
<figure v-for="(src, i) in preview.pages" :key="i">
<img :src="src" alt="Kartenvorschau" />
<figcaption class="muted">{{ i === 0 ? 'Vorderseite' : 'Rückseite' }}</figcaption>
</figure>
</div>
<p class="muted prev-note">Druckdaten inkl. Beschnitt &amp; Schnittmarken so wird gedruckt.</p>
</Modal>
</section> </section>
</template> </template>
@ -244,13 +303,22 @@ onMounted(async () => { await load(); if (canOrder.value) await loadRefs() })
.s-cancel { background: #f3f4f6; color: #6b7280; } .s-cancel { background: #f3f4f6; color: #6b7280; }
.items { border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; } .items { border: 1px solid var(--line); border-radius: var(--radius-sm); overflow: hidden; }
.items-head, .item-row { display: grid; grid-template-columns: 1fr 1fr 62px 28px; gap: .35rem; align-items: center; padding: .5rem .6rem; } .items-head, .item-row { display: grid; grid-template-columns: 1fr 1fr 64px 32px 32px; gap: .4rem; align-items: center; padding: .5rem .6rem; }
.items-head { font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); background: #fafafa; } .items-head { font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); background: #fafafa; }
.item-row { border-top: 1px solid #f4f4f4; } .item-row { border-top: 1px solid #f4f4f4; }
.items-head > *, .item-row > * { min-width: 0; } .items-head > *, .item-row > * { min-width: 0; }
.item-row :deep(.input) { padding-left: .5rem; padding-right: .3rem; } .item-row :deep(.input) { padding-left: .5rem; padding-right: .3rem; }
.item-row .qty { text-align: right; } .item-row .qty { text-align: right; }
.item-row .del { padding: 0; } .item-row .eye, .item-row .del { padding: 0; }
.item-row .del { color: var(--danger); }
.prev-sub { margin: -.4rem 0 1rem; }
.prev-loading { padding: 2rem; text-align: center; color: var(--muted); }
.prev-pages { display: flex; flex-wrap: wrap; gap: 1.2rem; justify-content: center; }
.prev-pages figure { margin: 0; text-align: center; }
.prev-pages img { width: 300px; max-width: 100%; border: 1px solid var(--line); border-radius: 6px; box-shadow: var(--shadow-sm); background: #fff; }
.prev-pages figcaption { font-size: .78rem; margin-top: .4rem; }
.prev-note { font-size: .8rem; margin: 1.1rem 0 0; text-align: center; }
.item-row .del { color: var(--danger); } .item-row .del { color: var(--danger); }
.addrow { margin: .6rem 0 1rem; } .addrow { margin: .6rem 0 1rem; }
.field { margin-top: .4rem; } .field { margin-top: .4rem; }