Bestellungen: Frontend (Firma bestellt, Reseller wickelt ab)

- OrdersView: Firma sieht eigene Bestellungen + „Neue Bestellung"
  (Positionen Produkt+Mitarbeiter+Menge); Reseller/Plattform sehen
  eingehende (mit Firma-Spalte), setzen Status vorwärts, stornieren,
  PDF je Position. Status-Badges, Detail-Modal.
- Nav-Eintrag „Bestellungen" (Firma + Reseller + Plattform), Route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-03 13:24:23 +02:00
parent b09931997b
commit 7c3b06c996
3 changed files with 265 additions and 0 deletions

View File

@ -15,6 +15,7 @@ const nav = computed<NavItem[]>(() => [
{ label: 'Mitarbeiter', to: '/app/employees', icon: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z', show: true },
{ label: 'Produkte', to: '/app/products', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12', show: auth.isResellerAdmin || auth.isPlatformAdmin },
{ label: 'Editor', to: '/app/card-editor', icon: 'M3 5h18v14H3zM3 10h18M7 15h5', show: auth.isResellerAdmin || auth.isCompanyAdmin },
{ label: 'Bestellungen', to: '/app/orders', icon: 'M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4zM3 6h18M16 10a4 4 0 0 1-8 0', show: auth.isCompanyAdmin || auth.isResellerAdmin || auth.isPlatformAdmin },
{ label: 'Standorte', to: '/app/locations', icon: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z', show: auth.isCompanyAdmin },
{ label: 'Domains', to: '/app/domains', icon: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z', show: auth.isCompanyAdmin },
{ label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: auth.isCompanyAdmin },

View File

@ -21,6 +21,7 @@ const router = createRouter({
{ path: 'employees', name: 'employees', component: () => import('@/views/EmployeesView.vue') },
{ path: 'products', name: 'products', component: () => import('@/views/ProductsView.vue') },
{ path: 'card-editor', name: 'card-editor', component: () => import('@/views/CardEditorView.vue') },
{ path: 'orders', name: 'orders', component: () => import('@/views/OrdersView.vue') },
{ path: 'locations', name: 'locations', component: () => import('@/views/LocationsView.vue') },
{ path: 'domains', name: 'domains', component: () => import('@/views/DomainsView.vue') },
{ path: 'design', name: 'design', component: () => import('@/views/DesignView.vue') },

View File

@ -0,0 +1,263 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import client from '@/api/client'
import { list } from '@/api/resources'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
interface OrderSummary {
id: string; number: string; status: string
company: { id: string; name: string }
itemCount: number; totalQuantity: number; createdAt: string
}
interface OrderItem {
id: string; quantity: number
product: { id: string; name: string; kind: string }
employee: { id: string; name: string }
pdfUrl: string
}
interface OrderDetail extends OrderSummary {
note: string | null; createdBy: string | null; items: OrderItem[]
}
interface Product { '@id': string; id: string; name: string; kind: string; active: boolean }
interface Employee { '@id': string; id: string; firstName: string; lastName: string }
const STATUS_LABEL: Record<string, string> = {
new: 'Neu', in_production: 'In Produktion', shipped: 'Versandt', completed: 'Erledigt', cancelled: 'Storniert',
}
const STATUS_CLASS: Record<string, string> = {
new: 's-new', in_production: 's-prod', shipped: 's-ship', completed: 's-done', cancelled: 's-cancel',
}
const NEXT: Record<string, string> = { new: 'in_production', in_production: 'shipped', shipped: 'completed' }
const auth = useAuthStore()
const canOrder = computed(() => auth.isCompanyAdmin)
const canFulfill = computed(() => auth.isResellerAdmin || auth.isPlatformAdmin)
const showCompanyCol = computed(() => canFulfill.value)
const orders = ref<OrderSummary[]>([])
const loading = ref(true)
const products = ref<Product[]>([])
const employees = ref<Employee[]>([])
const showCreate = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref<{ note: string; items: { product: string; employee: string; quantity: number }[] }>({
note: '', items: [{ product: '', employee: '', quantity: 100 }],
})
const detail = ref<OrderDetail | null>(null)
const busy = ref(false)
async function load() {
loading.value = true
const { data } = await client.get<{ member: OrderSummary[] }>('/orders')
orders.value = data.member ?? []
loading.value = false
}
async function loadRefs() {
;[products.value, employees.value] = await Promise.all([
list<Product>('products').then((r) => r.member.filter((p) => p.active)).catch(() => []),
list<Employee>('employees').then((r) => r.member).catch(() => []),
])
}
function openCreate() {
form.value = { note: '', items: [{ product: products.value[0]?.['@id'] ?? '', employee: '', quantity: 100 }] }
error.value = ''
showCreate.value = true
}
function addRow() {
form.value.items.push({ product: products.value[0]?.['@id'] ?? '', employee: '', quantity: 100 })
}
function removeRow(i: number) {
form.value.items.splice(i, 1)
}
function idOf(iri: string) { return iri.split('/').pop() as string }
async function submit() {
error.value = ''
const items = form.value.items
.filter((r) => r.product && r.employee && r.quantity > 0)
.map((r) => ({ product: idOf(r.product), employee: idOf(r.employee), quantity: Number(r.quantity) }))
if (!items.length) { error.value = 'Mindestens eine vollständige Position angeben.'; return }
saving.value = true
try {
await client.post('/orders', { note: form.value.note || null, items })
showCreate.value = false
await load()
} catch {
error.value = 'Bestellung konnte nicht angelegt werden.'
} finally {
saving.value = false
}
}
async function openDetail(o: OrderSummary) {
const { data } = await client.get<OrderDetail>(`/orders/${o.id}`)
detail.value = data
}
async function setStatus(status: string) {
if (!detail.value) return
busy.value = true
try {
const { data } = await client.patch<OrderDetail>(`/orders/${detail.value.id}/status`, { status })
detail.value = data
await load()
} finally { busy.value = false }
}
async function openPdf(item: OrderItem) {
const res = await client.get(item.pdfUrl.replace(/^\/api/, ''), { responseType: 'blob' })
window.open(URL.createObjectURL(res.data as Blob), '_blank')
}
function fmtDate(s: string) { return new Date(s).toLocaleDateString('de-DE') }
onMounted(async () => { await load(); if (canOrder.value) await loadRefs() })
</script>
<template>
<section>
<div class="page-head">
<div>
<h1>Bestellungen</h1>
<p class="muted">{{ canFulfill ? 'Eingehende Druckaufträge abwickeln' : 'Produkte für Mitarbeiter bestellen' }}</p>
</div>
<button v-if="canOrder" class="btn btn-primary" @click="openCreate">+ Neue Bestellung</button>
</div>
<div class="card">
<table class="tbl">
<thead>
<tr>
<th>Nr.</th>
<th v-if="showCompanyCol">Firma</th>
<th>Status</th><th>Positionen</th><th>Menge</th><th>Datum</th><th></th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td :colspan="showCompanyCol ? 7 : 6" class="empty">Lädt</td></tr>
<tr v-else-if="!orders.length"><td :colspan="showCompanyCol ? 7 : 6" class="empty">Noch keine Bestellungen.</td></tr>
<tr v-for="o in orders" :key="o.id" class="row" @click="openDetail(o)">
<td><strong>{{ o.number }}</strong></td>
<td v-if="showCompanyCol">{{ o.company.name }}</td>
<td><span class="badge" :class="STATUS_CLASS[o.status]">{{ STATUS_LABEL[o.status] }}</span></td>
<td class="muted">{{ o.itemCount }}</td>
<td>{{ o.totalQuantity }}</td>
<td class="muted">{{ fmtDate(o.createdAt) }}</td>
<td class="right"><button class="btn btn-ghost btn-sm" @click.stop="openDetail(o)">Details</button></td>
</tr>
</tbody>
</table>
</div>
<!-- Neue Bestellung -->
<Modal v-if="showCreate" title="Neue Bestellung" @close="showCreate = false">
<form @submit.prevent="submit">
<div class="items">
<div class="items-head"><span>Produkt</span><span>Mitarbeiter</span><span>Menge</span><span></span></div>
<div v-for="(row, i) in form.items" :key="i" class="item-row">
<select class="input" v-model="row.product" required>
<option v-for="p in products" :key="p['@id']" :value="p['@id']">{{ p.name }}</option>
</select>
<select class="input" v-model="row.employee" required>
<option value="" disabled> wählen </option>
<option v-for="e in employees" :key="e['@id']" :value="e['@id']">{{ e.firstName }} {{ e.lastName }}</option>
</select>
<input class="input qty" type="number" min="1" v-model.number="row.quantity" />
<button type="button" class="btn btn-ghost btn-sm del" :disabled="form.items.length === 1" @click="removeRow(i)" title="Position entfernen"></button>
</div>
</div>
<button type="button" class="btn btn-soft btn-sm addrow" @click="addRow">+ Position</button>
<div class="field">
<label>Notiz (optional)</label>
<input class="input" v-model="form.note" placeholder="z. B. Erstausstattung neues Team" />
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn btn-ghost" @click="showCreate = false">Abbrechen</button>
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Bestellen…' : 'Bestellen' }}</button>
</div>
</form>
</Modal>
<!-- Detail -->
<Modal v-if="detail" :title="`Bestellung ${detail.number}`" @close="detail = null">
<div class="detail-meta">
<span class="badge" :class="STATUS_CLASS[detail.status]">{{ STATUS_LABEL[detail.status] }}</span>
<span class="muted">{{ detail.company.name }} · {{ fmtDate(detail.createdAt) }}<template v-if="detail.createdBy"> · {{ detail.createdBy }}</template></span>
</div>
<p v-if="detail.note" class="note">{{ detail.note }}"</p>
<table class="tbl items-tbl">
<thead><tr><th>Produkt</th><th>Mitarbeiter</th><th>Menge</th><th></th></tr></thead>
<tbody>
<tr v-for="it in detail.items" :key="it.id">
<td><strong>{{ it.product.name }}</strong></td>
<td>{{ it.employee.name }}</td>
<td>{{ it.quantity }}</td>
<td class="right"><button class="btn btn-ghost btn-sm" @click="openPdf(it)">PDF</button></td>
</tr>
</tbody>
</table>
<div class="status-actions">
<!-- Reseller/Plattform: Status vorwärts -->
<button v-if="canFulfill && NEXT[detail.status]" class="btn btn-primary btn-sm" :disabled="busy"
@click="setStatus(NEXT[detail.status])">
{{ STATUS_LABEL[NEXT[detail.status]] }}
</button>
<!-- Stornieren: Reseller jederzeit, Firma solange neu" -->
<button v-if="(canFulfill || (canOrder && detail.status === 'new')) && detail.status !== 'cancelled' && detail.status !== 'completed'"
class="btn btn-ghost btn-sm cancel" :disabled="busy" @click="setStatus('cancelled')">
Stornieren
</button>
</div>
</Modal>
</section>
</template>
<style scoped>
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
.page-head .muted { margin: .2rem 0 0; }
.tbl { width: 100%; border-collapse: collapse; }
.tbl th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: .65rem .8rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
.tbl td { padding: .7rem .8rem; border-bottom: 1px solid #f4f4f4; }
.tbl tr:last-child td { border-bottom: none; }
.row { cursor: pointer; }
.row:hover { background: #fafafa; }
.right { text-align: right; white-space: nowrap; }
.empty { text-align: center; color: var(--muted); padding: 2rem; }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
.error { color: var(--danger); font-size: .88rem; }
.badge { font-size: .74rem; font-weight: 700; padding: .15rem .55rem; border-radius: 999px; }
.s-new { background: #eef2ff; color: #3730a3; }
.s-prod { background: #fff3e6; color: var(--psc-orange-dark); }
.s-ship { background: #e0f2fe; color: #075985; }
.s-done { background: #e7f8ec; color: #1b7a3d; }
.s-cancel { background: #f3f4f6; color: #6b7280; }
.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 { font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); background: #fafafa; }
.item-row { border-top: 1px solid #f4f4f4; }
.items-head > *, .item-row > * { min-width: 0; }
.item-row :deep(.input) { padding-left: .5rem; padding-right: .3rem; }
.item-row .qty { text-align: right; }
.item-row .del { padding: 0; }
.item-row .del { color: var(--danger); }
.addrow { margin: .6rem 0 1rem; }
.field { margin-top: .4rem; }
.detail-meta { display: flex; align-items: center; gap: .7rem; margin-bottom: .6rem; }
.note { font-style: italic; color: var(--muted); margin: 0 0 .8rem; }
.items-tbl { margin-bottom: 1rem; }
.status-actions { display: flex; gap: .6rem; justify-content: flex-end; }
.status-actions .cancel { color: var(--danger); }
</style>