- 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>
196 lines
9.5 KiB
Vue
196 lines
9.5 KiB
Vue
<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>
|