UI: „Firmen & Mitarbeiter"-Ansicht mit „Einloggen als" (scoped)
CompaniesView zeigt jetzt die Firmenkunden des Resellers (eigene Org-Firma via resellerOrg ausgeblendet) mit Kennzahlen (Standorte/Mitarbeiter/aktive Profile/ Domains/erstellt) und aufklappbarer Mitarbeiterliste. „Einloggen als" (Impersonation) je Firma (Firmen-Admin) und je Mitarbeiter mit Login → wechselt in den Firmen-Kontext (linke Navi). Nur Mitarbeiter/Firmen des eigenen Mandanten. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4be88dfd45
commit
d66c7cc4aa
@ -1,33 +1,74 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { list, create, remove } from '@/api/resources'
|
import { list, create, remove } from '@/api/resources'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
|
|
||||||
interface Company {
|
interface Company {
|
||||||
'@id': string
|
'@id': string; id: string; name: string; slug: string; status: string
|
||||||
id: string
|
resellerOrg: boolean
|
||||||
name: string
|
locations: string[]; employees: string[]; domains: string[]; createdAt: string
|
||||||
slug: string
|
}
|
||||||
status: string
|
interface Employee {
|
||||||
|
'@id': string; id: string; firstName: string; lastName: string
|
||||||
|
email: string | null; phone: string | null; position: string | null
|
||||||
|
status: string; company: string; login: boolean; roles: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const companies = ref<Company[]>([])
|
const companies = ref<Company[]>([])
|
||||||
|
const employees = ref<Employee[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const expanded = ref<Record<string, boolean>>({})
|
||||||
|
const busy = ref('')
|
||||||
|
|
||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const form = ref({ name: '', slug: '' })
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const form = ref({ name: '', slug: '' })
|
||||||
|
|
||||||
function slugify(s: string) {
|
function slugify(s: string) {
|
||||||
return s.toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-')
|
return s.toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-')
|
||||||
}
|
}
|
||||||
|
function empOf(c: Company) {
|
||||||
|
return employees.value.filter((e) => e.company === c['@id'])
|
||||||
|
}
|
||||||
|
function activeProfiles(c: Company) {
|
||||||
|
return empOf(c).filter((e) => e.status === 'active').length
|
||||||
|
}
|
||||||
|
function companyAdmin(c: Company) {
|
||||||
|
return empOf(c).find((e) => e.login && e.roles.includes('ROLE_COMPANY_ADMIN'))
|
||||||
|
}
|
||||||
|
function initial(name: string) { return (name[0] || '?').toUpperCase() }
|
||||||
|
function fmtDate(s: string) { return new Date(s).toLocaleDateString('de-DE') }
|
||||||
|
|
||||||
|
// Nur Kundenfirmen (eigene Reseller-Org-Firma ausblenden)
|
||||||
|
const customers = computed(() => companies.value.filter((c) => !c.resellerOrg))
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
companies.value = (await list<Company>('companies')).member
|
;[companies.value, employees.value] = await Promise.all([
|
||||||
|
list<Company>('companies').then((r) => r.member),
|
||||||
|
list<Employee>('employees').then((r) => r.member).catch(() => []),
|
||||||
|
])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggle(c: Company) { expanded.value[c.id] = !expanded.value[c.id] }
|
||||||
|
|
||||||
|
async function loginAs(employeeId: string) {
|
||||||
|
busy.value = employeeId
|
||||||
|
try {
|
||||||
|
await auth.impersonate(employeeId)
|
||||||
|
router.push('/app')
|
||||||
|
} catch {
|
||||||
|
error.value = 'Einloggen als … fehlgeschlagen.'
|
||||||
|
} finally { busy.value = '' }
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@ -38,11 +79,8 @@ async function submit() {
|
|||||||
await load()
|
await load()
|
||||||
} catch {
|
} catch {
|
||||||
error.value = 'Speichern fehlgeschlagen.'
|
error.value = 'Speichern fehlgeschlagen.'
|
||||||
} finally {
|
} finally { saving.value = false }
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(c: Company) {
|
async function del(c: Company) {
|
||||||
if (!confirm(`Firma „${c.name}" wirklich löschen?`)) return
|
if (!confirm(`Firma „${c.name}" wirklich löschen?`)) return
|
||||||
await remove(c['@id'])
|
await remove(c['@id'])
|
||||||
@ -56,40 +94,77 @@ onMounted(load)
|
|||||||
<section>
|
<section>
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>Firmen</h1>
|
<h1>Firmen & Mitarbeiter</h1>
|
||||||
<p class="muted">Firmenkunden verwalten</p>
|
<p class="muted">Verwalten Sie Ihre Firmenkunden und deren Mitarbeiter.</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" @click="showCreate = true">+ Firma hinzufügen</button>
|
<button class="btn btn-primary" @click="showCreate = true">+ Neue Firma hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<table class="tbl">
|
<table class="tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Name</th><th>Slug</th><th>Status</th><th></th></tr>
|
<tr>
|
||||||
|
<th class="w-caret"></th><th>Firma</th>
|
||||||
|
<th class="num">Standorte</th><th class="num">Mitarbeiter</th>
|
||||||
|
<th class="num">Aktive Profile</th><th class="num">Domains</th>
|
||||||
|
<th>Erstellt am</th><th></th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="loading"><td colspan="4" class="empty">Lädt…</td></tr>
|
<tr v-if="loading"><td colspan="8" class="empty">Lädt…</td></tr>
|
||||||
<tr v-else-if="!companies.length"><td colspan="4" class="empty">Noch keine Firmen.</td></tr>
|
<tr v-else-if="!customers.length"><td colspan="8" class="empty">Noch keine Firmenkunden.</td></tr>
|
||||||
<tr v-for="c in companies" :key="c.id">
|
<template v-for="c in customers" :key="c.id">
|
||||||
<td><strong>{{ c.name }}</strong></td>
|
<tr class="crow" @click="toggle(c)">
|
||||||
<td class="muted">{{ c.slug }}</td>
|
<td class="w-caret"><span class="caret" :class="{ open: expanded[c.id] }">›</span></td>
|
||||||
<td><span class="badge" :class="c.status === 'active' ? 'badge-active' : 'badge-inactive'">{{ c.status }}</span></td>
|
<td>
|
||||||
<td class="right"><button class="btn btn-ghost btn-sm" @click="del(c)">Löschen</button></td>
|
<div class="firma">
|
||||||
</tr>
|
<span class="avatar">{{ initial(c.name) }}</span>
|
||||||
|
<div><strong>{{ c.name }}</strong><div class="muted sm">{{ c.slug }}</div></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ c.locations.length }}</td>
|
||||||
|
<td class="num">{{ empOf(c).length }}</td>
|
||||||
|
<td class="num"><span class="green">{{ activeProfiles(c) }}</span></td>
|
||||||
|
<td class="num">{{ c.domains.length }}</td>
|
||||||
|
<td class="muted">{{ fmtDate(c.createdAt) }}</td>
|
||||||
|
<td class="right" @click.stop>
|
||||||
|
<button v-if="companyAdmin(c)" class="btn btn-soft btn-sm" :disabled="busy !== ''"
|
||||||
|
@click="loginAs(companyAdmin(c)!.id)">Einloggen als</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="del(c)">Löschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="expanded[c.id]" class="subrow">
|
||||||
|
<td></td>
|
||||||
|
<td colspan="7">
|
||||||
|
<table class="subtbl">
|
||||||
|
<thead><tr><th>Mitarbeiter</th><th>E-Mail</th><th>Telefon</th><th>Position</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="!empOf(c).length"><td colspan="5" class="muted sm">Keine Mitarbeiter.</td></tr>
|
||||||
|
<tr v-for="e in empOf(c)" :key="e.id">
|
||||||
|
<td><strong>{{ e.firstName }} {{ e.lastName }}</strong></td>
|
||||||
|
<td class="muted">{{ e.email || '–' }}</td>
|
||||||
|
<td class="muted">{{ e.phone || '–' }}</td>
|
||||||
|
<td>{{ e.position || '–' }}</td>
|
||||||
|
<td class="right">
|
||||||
|
<button v-if="e.login" class="btn btn-ghost btn-sm orange" :disabled="busy !== ''"
|
||||||
|
@click="loginAs(e.id)">⤷ Einloggen als</button>
|
||||||
|
<span v-else class="muted sm">kein Login</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="error" class="error" style="margin-top:.6rem">{{ error }}</p>
|
||||||
|
|
||||||
<Modal v-if="showCreate" title="Firma hinzufügen" @close="showCreate = false">
|
<Modal v-if="showCreate" title="Neue Firma hinzufügen" @close="showCreate = false">
|
||||||
<form @submit.prevent="submit">
|
<form @submit.prevent="submit">
|
||||||
<div class="field">
|
<div class="field"><label>Firmenname</label><input class="input" v-model="form.name" required placeholder="Muster GmbH" /></div>
|
||||||
<label>Firmenname</label>
|
<div class="field"><label>Slug (optional)</label><input class="input" v-model="form.slug" :placeholder="slugify(form.name) || 'muster-gmbh'" /></div>
|
||||||
<input class="input" v-model="form.name" required placeholder="Muster GmbH" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Slug (optional)</label>
|
|
||||||
<input class="input" v-model="form.slug" :placeholder="slugify(form.name) || 'muster-gmbh'" />
|
|
||||||
</div>
|
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class="btn btn-ghost" @click="showCreate = false">Abbrechen</button>
|
<button type="button" class="btn btn-ghost" @click="showCreate = false">Abbrechen</button>
|
||||||
@ -101,14 +176,31 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
|
.page-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1.4rem; }
|
||||||
.page-head .muted { margin: .2rem 0 0; }
|
.page-head .muted { margin: .2rem 0 0; }
|
||||||
.tbl { width: 100%; border-collapse: collapse; }
|
.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 th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: .8rem 1rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||||
.tbl td { padding: .9rem 1.2rem; border-bottom: 1px solid #f4f4f4; }
|
.tbl > tbody > tr > td { padding: .8rem 1rem; border-bottom: 1px solid #f4f4f4; vertical-align: middle; }
|
||||||
.tbl tr:last-child td { border-bottom: none; }
|
.num { text-align: center; }
|
||||||
.right { text-align: right; }
|
.right { text-align: right; white-space: nowrap; }
|
||||||
|
.w-caret { width: 28px; }
|
||||||
|
.caret { display: inline-block; transition: transform .15s; color: var(--muted); font-size: 1.1rem; }
|
||||||
|
.caret.open { transform: rotate(90deg); }
|
||||||
|
.crow { cursor: pointer; }
|
||||||
|
.crow:hover { background: #fafafa; }
|
||||||
|
.firma { display: flex; align-items: center; gap: .7rem; }
|
||||||
|
.avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--psc-orange-soft); color: var(--psc-orange-dark); display: flex; align-items: center; justify-content: center; font-weight: 700; flex-shrink: 0; }
|
||||||
|
.sm { font-size: .8rem; }
|
||||||
|
.green { color: var(--success); font-weight: 700; }
|
||||||
.empty { text-align: center; color: var(--muted); padding: 2rem; }
|
.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; }
|
.error { color: var(--danger); font-size: .88rem; }
|
||||||
|
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1rem; }
|
||||||
|
.right .btn + .btn { margin-left: .4rem; }
|
||||||
|
.orange { color: var(--psc-orange-dark); }
|
||||||
|
|
||||||
|
/* Sub-Tabelle (Mitarbeiter) */
|
||||||
|
.subrow > td { background: #fbfbfb; padding: 0 1rem 1rem !important; border-bottom: 1px solid #f4f4f4 !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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user