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:
parent
b09931997b
commit
7c3b06c996
@ -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 },
|
||||
|
||||
@ -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') },
|
||||
|
||||
263
frontend/src/views/OrdersView.vue
Normal file
263
frontend/src/views/OrdersView.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user