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:
parent
7c3b06c996
commit
bbe7c1b71c
@ -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);
|
||||||
|
|||||||
@ -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 & 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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user