vcard4reseller/frontend/src/views/CompaniesView.vue
Thomas Peterson 9bd8b45588 UI: Einloggen-als-Mitarbeiterliste im Screenshot-Layout
Aufgeklappte Firmen-Mitarbeiterliste: Spalten Mitarbeiter · E-Mail · Telefon ·
Position · Aktionen; „Einloggen als" als orange umrandeter Button mit Login-Icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 10:45:13 +02:00

220 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, create, remove } from '@/api/resources'
import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue'
interface Company {
'@id': string; id: string; name: string; slug: string; status: string
resellerOrg: boolean
locations: string[]; employees: string[]; domains: string[]; createdAt: 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 employees = ref<Employee[]>([])
const loading = ref(true)
const expanded = ref<Record<string, boolean>>({})
const busy = ref('')
const showCreate = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref({ name: '', slug: '' })
function slugify(s: string) {
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'])
}
// Nur Mitarbeiter, deren Login man übernehmen kann
function loginable(c: Company) {
return empOf(c).filter((e) => e.login)
}
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() {
loading.value = true
;[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
}
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() {
error.value = ''
saving.value = true
try {
await create('companies', { name: form.value.name, slug: form.value.slug || slugify(form.value.name) })
showCreate.value = false
form.value = { name: '', slug: '' }
await load()
} catch {
error.value = 'Speichern fehlgeschlagen.'
} finally { saving.value = false }
}
async function del(c: Company) {
if (!confirm(`Firma „${c.name}" wirklich löschen?`)) return
await remove(c['@id'])
await load()
}
onMounted(load)
</script>
<template>
<section>
<div class="page-head">
<div>
<h1>Firmen &amp; Mitarbeiter</h1>
<p class="muted">Verwalten Sie Ihre Firmenkunden und deren Mitarbeiter.</p>
</div>
<button class="btn btn-primary" @click="showCreate = true">+ Neue Firma hinzufügen</button>
</div>
<div class="card">
<table class="tbl">
<thead>
<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>
<tbody>
<tr v-if="loading"><td colspan="8" class="empty">Lädt…</td></tr>
<tr v-else-if="!customers.length"><td colspan="8" class="empty">Noch keine Firmenkunden.</td></tr>
<template v-for="c in customers" :key="c.id">
<tr class="crow" @click="toggle(c)">
<td class="w-caret"><span class="caret" :class="{ open: expanded[c.id] }"></span></td>
<td>
<div class="firma">
<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 class="right">Aktionen</th></tr></thead>
<tbody>
<tr v-if="!loginable(c).length"><td colspan="5" class="muted sm">Keine einloggbaren Mitarbeiter.</td></tr>
<tr v-for="e in loginable(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 class="btn-loginas" :disabled="busy !== ''" @click="loginAs(e.id)">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3" /></svg>
Einloggen als
</button>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<p v-if="error" class="error" style="margin-top:.6rem">{{ error }}</p>
<Modal v-if="showCreate" title="Neue Firma hinzufügen" @close="showCreate = false">
<form @submit.prevent="submit">
<div class="field"><label>Firmenname</label><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>
<div class="actions">
<button type="button" class="btn btn-ghost" @click="showCreate = 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: flex-start; 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: .8rem 1rem; border-bottom: 1px solid var(--line); white-space: nowrap; }
.tbl > tbody > tr > td { padding: .8rem 1rem; border-bottom: 1px solid #f4f4f4; vertical-align: middle; }
.num { text-align: center; }
.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; }
.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 th.right { text-align: right; }
.subtbl td { padding: .6rem .7rem; border-top: 1px solid #f0f0f0; vertical-align: middle; }
.btn-loginas {
display: inline-flex; align-items: center; gap: .4rem; padding: .42rem .85rem;
border: 1px solid var(--psc-orange); background: #fff; color: var(--psc-orange-dark);
border-radius: 8px; font-weight: 600; font-size: .82rem; cursor: pointer; font-family: var(--font);
}
.btn-loginas:hover { background: var(--psc-orange-soft); }
.btn-loginas:disabled { opacity: .55; cursor: default; }
</style>