vcard4reseller/frontend/src/views/ResellersView.vue
Thomas Peterson cc92b48869 UI: Reseller aufklappbar (Admins) + Firmen-Aufklapp nur einloggbare
- ResellersView: Zeilen aufklappbar → Reseller-Admins (login + ROLE_RESELLER_ADMIN)
  je „Einloggen als"; Plattform-Org ohne Admins bleibt zu.
- CompaniesView: Aufklapp listet nur noch einloggbare Mitarbeiter (login=true),
  „kein Login"-Zeilen entfallen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 18:24:56 +02:00

196 lines
9.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { list, remove } from '@/api/resources'
import client from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
interface Reseller {
'@id': string; id: string; name: string; slug: string
primaryDomain: string | null; status: string
platformPlan: string | null; companies: string[]
}
interface Plan { '@id': string; id: string; name: string }
interface Company { '@id': string; reseller: string | null }
interface Employee { '@id': string; id: string; firstName: string; lastName: string; email: string | null; company: string; roles: string[]; login: boolean }
const auth = useAuthStore()
const router = useRouter()
const resellers = ref<Reseller[]>([])
const plans = ref<Plan[]>([])
const companies = ref<Company[]>([])
const employees = ref<Employee[]>([])
const busy = ref('')
const loading = ref(true)
const expanded = ref<Record<string, boolean>>({})
function toggle(r: Reseller) { expanded.value[r.id] = !expanded.value[r.id] }
// Firma-IRI → Reseller-IRI, um die einloggbaren Reseller-Admins je Reseller zu finden
const companyReseller = computed(() => Object.fromEntries(companies.value.map((c) => [c['@id'], c.reseller])))
function resellerAdmins(r: Reseller): Employee[] {
return employees.value.filter((e) =>
e.login && e.roles.includes('ROLE_RESELLER_ADMIN') && companyReseller.value[e.company] === r['@id'],
)
}
function resellerAdmin(r: Reseller): Employee | undefined { return resellerAdmins(r)[0] }
async function loginAs(employeeId: string) {
busy.value = employeeId
try {
await auth.impersonate(employeeId)
router.push('/app')
} catch {
alert('Einloggen als … fehlgeschlagen.')
} finally { busy.value = '' }
}
const showForm = ref(false)
const saving = ref(false)
const error = ref('')
const blank = () => ({ name: '', slug: '', primaryDomain: '', planId: '', adminEmail: '', adminPassword: '' })
const form = ref(blank())
const planMap = computed(() => Object.fromEntries(plans.value.map((p) => [p['@id'], p.name])))
function slugify(s: string) {
return s.toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-')
}
async function load() {
loading.value = true
;[resellers.value, plans.value, companies.value, employees.value] = await Promise.all([
list<Reseller>('resellers').then((r) => r.member),
list<Plan>('platform_plans').then((r) => r.member).catch(() => []),
list<Company>('companies').then((r) => r.member).catch(() => []),
list<Employee>('employees').then((r) => r.member).catch(() => []),
])
loading.value = false
}
function openCreate() { form.value = blank(); if (plans.value[0]) form.value.planId = plans.value[0].id; error.value = ''; showForm.value = true }
async function submit() {
saving.value = true; error.value = ''
try {
await client.post('/platform/provision-reseller', {
name: form.value.name,
slug: form.value.slug || slugify(form.value.name),
primaryDomain: form.value.primaryDomain || null,
planId: form.value.planId || null,
adminEmail: form.value.adminEmail || null,
adminPassword: form.value.adminPassword || null,
})
showForm.value = false; await load()
} catch (e: any) {
error.value = e?.response?.data?.error ?? 'Speichern fehlgeschlagen.'
} finally { saving.value = false }
}
async function del(r: Reseller) {
if (!confirm(`Reseller „${r.name}" löschen? (nur möglich, wenn keine Firmen mehr zugeordnet sind)`)) return
try { await remove(r['@id']); await load() }
catch { alert('Löschen fehlgeschlagen evtl. sind noch Firmen zugeordnet.') }
}
onMounted(load)
</script>
<template>
<section>
<div class="page-head">
<div><h1>Reseller</h1><p class="muted">Druckereien & Agenturen auf der Plattform</p></div>
<button class="btn btn-primary" @click="openCreate">+ Reseller anlegen</button>
</div>
<div class="card">
<table class="tbl">
<thead><tr><th class="w-caret"></th><th>Name</th><th>Domain</th><th>Paket</th><th>Firmen</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr v-if="loading"><td colspan="7" class="empty">Lädt…</td></tr>
<tr v-else-if="!resellers.length"><td colspan="7" class="empty">Noch keine Reseller.</td></tr>
<template v-for="r in resellers" :key="r.id">
<tr :class="{ rrow: resellerAdmins(r).length }" @click="resellerAdmins(r).length && toggle(r)">
<td class="w-caret"><span v-if="resellerAdmins(r).length" class="caret" :class="{ open: expanded[r.id] }"></span></td>
<td><strong>{{ r.name }}</strong><div class="muted small">{{ r.slug }}</div></td>
<td class="muted">{{ r.primaryDomain ?? '' }}</td>
<td>{{ r.platformPlan ? (planMap[r.platformPlan] ?? '—') : '—' }}</td>
<td>{{ r.companies?.length ?? 0 }}</td>
<td><span class="badge" :class="r.status === 'active' ? 'badge-active' : 'badge-inactive'">{{ r.status }}</span></td>
<td class="right" @click.stop>
<button v-if="resellerAdmin(r)" class="btn btn-soft btn-sm" :disabled="busy !== ''"
@click="loginAs(resellerAdmin(r)!.id)">Einloggen als</button>
<button class="btn btn-ghost btn-sm" @click="del(r)">Löschen</button>
</td>
</tr>
<tr v-if="expanded[r.id]" class="subrow">
<td></td>
<td colspan="6">
<table class="subtbl">
<thead><tr><th>Reseller-Admin</th><th>E-Mail</th><th></th></tr></thead>
<tbody>
<tr v-for="a in resellerAdmins(r)" :key="a.id">
<td><strong>{{ a.firstName }} {{ a.lastName }}</strong></td>
<td class="muted">{{ a.email || '' }}</td>
<td class="right"><button class="btn btn-ghost btn-sm orange" :disabled="busy !== ''" @click="loginAs(a.id)">⤷ Einloggen als</button></td>
</tr>
</tbody>
</table>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<Modal v-if="showForm" title="Reseller anlegen" @close="showForm = false">
<form @submit.prevent="submit">
<div class="field"><label>Name</label><input class="input" v-model="form.name" required placeholder="Muster Druck GmbH" /></div>
<div class="grid2">
<div class="field"><label>Slug</label><input class="input" v-model="form.slug" :placeholder="slugify(form.name) || 'muster-druck'" /></div>
<div class="field"><label>Haupt-Domain</label><input class="input" v-model="form.primaryDomain" placeholder="muster.vcard4reseller.de" /></div>
</div>
<div class="field">
<label>Paket</label>
<select class="input" v-model="form.planId">
<option value=""> kein Paket </option>
<option v-for="p in plans" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="divider">Admin-Zugang (optional)</div>
<div class="grid2">
<div class="field"><label>Admin-E-Mail</label><input class="input" v-model="form.adminEmail" type="email" placeholder="admin@muster.de" /></div>
<div class="field"><label>Passwort</label><input class="input" v-model="form.adminPassword" type="password" placeholder="••••••••" /></div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button type="button" class="btn btn-ghost" @click="showForm = false">Abbrechen</button>
<button class="btn btn-primary" :disabled="saving">{{ saving ? 'Speichern' : 'Speichern' }}</button>
</div>
</form>
</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: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: .9rem 1.2rem; border-bottom: 1px solid var(--line); }
.tbl td { padding: .9rem 1.2rem; border-bottom: 1px solid #f4f4f4; }
.tbl tr:last-child td { border-bottom: none; }
.small { font-size: .8rem; }
.right { text-align: right; white-space: nowrap; }
.right .btn + .btn { margin-left: .4rem; }
.empty { text-align: center; color: var(--muted); padding: 2rem; }
.w-caret { width: 28px; }
.caret { display: inline-block; transition: transform .15s; color: var(--muted); font-size: 1.1rem; }
.caret.open { transform: rotate(90deg); }
.rrow { cursor: pointer; }
.rrow:hover { background: #fafafa; }
.orange { color: var(--psc-orange-dark); }
.subrow > td { background: #fbfbfb; padding: 0 1.2rem 1rem !important; }
.subtbl { width: 100%; border-collapse: collapse; }
.subtbl th { text-align: left; font-size: .68rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: .6rem .7rem; }
.subtbl td { padding: .55rem .7rem; border-top: 1px solid #f0f0f0; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
.divider { font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; margin: .4rem 0 .8rem; padding-top: .8rem; border-top: 1px solid var(--line); }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
.error { color: var(--danger); font-size: .88rem; }
</style>