Compare commits

...

3 Commits

Author SHA1 Message Date
d66c7cc4aa 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>
2026-06-06 08:56:44 +02:00
4be88dfd45 UI: Zwei-Ebenen-Navigation (Portal/Reseller oben, Firma links)
Klare Trennung der Ebenen gegen Verwechslung: Topbar trägt die Portal-/Reseller-
Navigation (Dashboard, Reseller, Firmen, Produkte, Bestellungen, Einstellungen)
+ Level-Badge (PORTAL/RESELLER/Firmenname). Die linke Sidebar zeigt NUR Firmen-
Ebene (Mitarbeiter, Editor, Bestellungen, Standorte, Domains, Design, Wallet,
Einstellungen) und nur im Firmen-Kontext (Firmen-Admin oder via „Einloggen als").
Reseller/Portal-Admin → links leer, Inhalt volle Breite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 08:53:20 +02:00
faece5870d Deploy-Fix: nginx routet /w (Wallet) zum Backend + nginx-Recreate bei Rollout
- nginx.prod.conf: /w (Wallet-Landing, .pkpass, Google-Redirect, Logo) fehlte in
  der Backend-Location-Regex → Wallet-Routen landeten in der SPA (index.html).
- update.sh: nginx.prod.conf ist ein Single-File-Bind-Mount (am Inode gepinnt);
  git reset ersetzt die Datei → nginx-Container force-recreaten, damit die
  aktuelle Config greift (statt nur reload).

Live-Nodes bereits nachgezogen; Apple-Wallet-Pass funktioniert über Caddy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 09:17:30 +02:00
4 changed files with 252 additions and 127 deletions

View File

@ -1,5 +1,5 @@
# Routing auf dem App-Node: # Routing auf dem App-Node:
# /api, /p, /t, /css, /bundles, /health → Symfony (PHP-FPM) # /api, /p, /t, /w, /css, /bundles, /health → Symfony (PHP-FPM)
# alles andere → Vue-SPA (history-fallback) # alles andere → Vue-SPA (history-fallback)
server { server {
listen 80; listen 80;
@ -12,7 +12,7 @@ server {
client_max_body_size 32m; client_max_body_size 32m;
# Symfony-Pfade (API + serverseitige öffentliche Seiten + interne Endpunkte) # Symfony-Pfade (API + serverseitige öffentliche Seiten + interne Endpunkte)
location ~ ^/(api|p|t|css|bundles|health|internal)(/|$) { location ~ ^/(api|p|t|w|css|bundles|health|internal)(/|$) {
root /app/public; root /app/public;
try_files $uri /index.php$is_args$args; try_files $uri /index.php$is_args$args;
} }

View File

@ -17,6 +17,9 @@ docker run --rm -e VITE_PUBLIC_BASE="https://$DOMAIN" -v "$REPO_DIR/frontend":/a
chown -R 1000:1000 "$REPO_DIR" chown -R 1000:1000 "$REPO_DIR"
$COMPOSE up -d --build $COMPOSE up -d --build
# nginx.prod.conf ist ein Single-File-Bind-Mount (am Inode gepinnt). git reset ersetzt
# die Datei (neuer Inode) → Container neu erzeugen, damit die aktuelle Config gemountet wird.
$COMPOSE up -d --force-recreate nginx
sleep 8 sleep 8
# PHP-Abhängigkeiten + Autoloader (vendor/ gitignored, /app als Volume gemountet → # PHP-Abhängigkeiten + Autoloader (vendor/ gitignored, /app als Volume gemountet →

View File

@ -9,24 +9,40 @@ const router = useRouter()
interface NavItem { label: string; to: string; icon: string; show: boolean } interface NavItem { label: string; to: string; icon: string; show: boolean }
const nav = computed<NavItem[]>(() => [ // Portal-/Reseller-Ebene Topbar
{ label: 'Dashboard', to: '/app', icon: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z', show: true }, const topNav = computed<NavItem[]>(() => {
const above = auth.isResellerAdmin || auth.isPlatformAdmin
return [
{ label: 'Dashboard', to: '/app', icon: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z', show: above },
{ label: 'Reseller', to: '/app/resellers', icon: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-3', show: auth.isPlatformAdmin }, { label: 'Reseller', to: '/app/resellers', icon: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-3', show: auth.isPlatformAdmin },
{ label: 'Firmen', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: auth.isResellerAdmin || auth.isPlatformAdmin }, { label: 'Firmen', to: '/app/companies', icon: 'M3 7h18v13H3zM8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', show: above },
{ 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: above },
{ 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: '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: above },
{ label: 'Editor', to: '/app/card-editor', icon: 'M3 5h18v14H3zM3 10h18M7 15h5', show: auth.isResellerAdmin || auth.isCompanyAdmin }, { label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: above },
{ 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 }, ].filter((i) => i.show)
{ 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 },
{ label: 'Wallet', to: '/app/wallet', icon: 'M3 7h18v12H3zM3 10h18M16 14h2', show: auth.isCompanyAdmin },
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: true },
].filter((i) => i.show))
const contextLabel = computed(() => // Firmen-Ebene linke Sidebar (nur im Firmen-Kontext, auch via Einloggen als")
auth.user?.company?.name ?? auth.user?.reseller?.name ?? 'Plattform', const leftNav = computed<NavItem[]>(() => {
) const inCompany = auth.isCompanyAdmin
return [
{ label: 'Dashboard', to: '/app', icon: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z', show: inCompany },
{ 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: inCompany },
{ label: 'Editor', to: '/app/card-editor', icon: 'M3 5h18v14H3zM3 10h18M7 15h5', show: inCompany },
{ 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: inCompany },
{ 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: inCompany },
{ 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: inCompany },
{ label: 'Design', to: '/app/design', icon: 'M12 2l7 7a7 7 0 1 1-14 0z', show: inCompany },
{ label: 'Wallet', to: '/app/wallet', icon: 'M3 7h18v12H3zM3 10h18M16 14h2', show: inCompany },
{ label: 'Einstellungen', to: '/app/settings', icon: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6', show: inCompany },
].filter((i) => i.show)
})
const levelLabel = computed(() => {
if (auth.isPlatformAdmin) return 'Portal'
if (auth.isResellerAdmin) return 'Reseller'
return auth.user?.company?.name ?? 'Firma'
})
const userName = computed(() => auth.user?.name || auth.user?.email || '') const userName = computed(() => auth.user?.name || auth.user?.email || '')
const initials = computed(() => const initials = computed(() =>
userName.value.split(/[\s@.]+/).filter(Boolean).slice(0, 2).map((s) => s[0]).join('').toUpperCase() || 'U', userName.value.split(/[\s@.]+/).filter(Boolean).slice(0, 2).map((s) => s[0]).join('').toUpperCase() || 'U',
@ -42,7 +58,6 @@ function logout() {
auth.logout() auth.logout()
router.push('/login') router.push('/login')
} }
async function stopImpersonation() { async function stopImpersonation() {
await auth.stopImpersonation() await auth.stopImpersonation()
router.push('/app') router.push('/app')
@ -51,12 +66,44 @@ async function stopImpersonation() {
<template> <template>
<div class="shell"> <div class="shell">
<aside class="sidebar"> <header class="topbar">
<div class="sidebar__brand"> <div class="topbar__brand">
<BrandLogo size="1.3rem" /> <BrandLogo size="1.2rem" />
<span class="level" :class="'level--' + (auth.isPlatformAdmin ? 'portal' : auth.isResellerAdmin ? 'reseller' : 'firma')">{{ levelLabel }}</span>
</div> </div>
<nav class="topnav" v-if="topNav.length">
<RouterLink v-for="item in topNav" :key="item.to" :to="item.to" class="topnav__link"
:class="{ active: $route.path === item.to }">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"><path :d="item.icon" /></svg>
<span>{{ item.label }}</span>
</RouterLink>
</nav>
<div v-else class="topnav-spacer"></div>
<div class="topbar__user">
<div class="user">
<div class="user__avatar">{{ initials }}</div>
<div class="user__meta">
<strong>{{ userName }}</strong>
<span class="muted">{{ roleLabel }}<template v-if="auth.user?.company"> · {{ auth.user.company.name }}</template></span>
</div>
</div>
<button class="btn btn-ghost btn-sm" @click="logout">Abmelden</button>
</div>
</header>
<div v-if="auth.isImpersonating" class="imp-banner">
<span>Eingeloggt als <strong>{{ auth.actingAs?.name }}</strong> ({{ auth.actingAs?.email }})</span>
<button class="btn btn-sm" @click="stopImpersonation">Zurück zur eigenen Ebene</button>
</div>
<div class="body">
<aside v-if="leftNav.length" class="sidebar">
<div class="sidebar__label">Firma</div>
<nav class="sidebar__nav"> <nav class="sidebar__nav">
<RouterLink v-for="item in nav" :key="item.to" :to="item.to" class="navlink" <RouterLink v-for="item in leftNav" :key="item.to" :to="item.to" class="navlink"
:class="{ active: $route.path === item.to }"> :class="{ active: $route.path === item.to }">
<span class="navlink__icon"> <span class="navlink__icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2"
@ -69,75 +116,58 @@ async function stopImpersonation() {
</nav> </nav>
</aside> </aside>
<div class="main"> <main class="content"><RouterView /></main>
<div v-if="auth.isImpersonating" class="imp-banner">
<span>Du arbeitest als <strong>{{ auth.actingAs?.name }}</strong> ({{ auth.actingAs?.email }})</span>
<button class="btn btn-sm" @click="stopImpersonation">Beenden</button>
</div>
<header class="topbar">
<div class="topbar__ctx">
<span class="muted">Mandant</span>
<strong>{{ contextLabel }}</strong>
</div>
<div class="topbar__user">
<div class="user">
<div class="user__avatar">{{ initials }}</div>
<div class="user__meta">
<strong>{{ userName }}</strong>
<span class="muted">{{ roleLabel }}<template v-if="auth.user?.company"> · {{ auth.user.company.name }}</template></span>
</div>
</div>
<button class="btn btn-ghost btn-sm" @click="logout">Abmelden</button>
</div>
</header>
<main class="content">
<RouterView />
</main>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.shell { display: flex; min-height: 100vh; background: var(--bg); } .shell { min-height: 100vh; background: var(--bg); display: flex; flex-direction: column; }
.sidebar { /* Topbar (Portal-/Reseller-Ebene) */
width: 248px; flex-shrink: 0; background: #fff; color: var(--text); .topbar {
border-right: 1px solid var(--line); display: flex; align-items: center; gap: 1.2rem; padding: .6rem 1.4rem;
display: flex; flex-direction: column; position: sticky; top: 0; height: 100vh; background: #fff; border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20;
} }
.sidebar__brand { padding: 1.5rem 1.4rem 1.2rem; font-size: 1.2rem; } .topbar__brand { display: flex; align-items: center; gap: .7rem; flex-shrink: 0; }
.sidebar__nav { display: flex; flex-direction: column; gap: 3px; padding: .4rem .7rem; overflow-y: auto; } .level { font-size: .72rem; font-weight: 700; padding: .2rem .6rem; border-radius: 999px; text-transform: uppercase; letter-spacing: .04em; }
.navlink { .level--portal { background: #ede9fe; color: #5b21b6; }
display: flex; align-items: center; gap: .75rem; padding: .62rem .7rem; .level--reseller { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
border-radius: var(--radius-sm); color: #5b5b5b; font-size: .92rem; font-weight: 600; .level--firma { background: #e8f0fe; color: #2b6cb0; }
.topnav { display: flex; align-items: center; gap: .3rem; flex: 1; flex-wrap: wrap; }
.topnav-spacer { flex: 1; }
.topnav__link {
display: inline-flex; align-items: center; gap: .45rem; padding: .5rem .85rem;
border-radius: 999px; color: #5b5b5b; font-size: .9rem; font-weight: 600;
} }
.topnav__link:hover { background: #f5f5f5; color: var(--text); text-decoration: none; }
.topnav__link.active { background: var(--psc-orange); color: #fff; }
.topbar__user { display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
.user { display: flex; align-items: center; gap: .6rem; }
.user__avatar { width: 38px; height: 38px; border-radius: 50%; flex-shrink: 0; background: var(--psc-orange-soft); color: var(--psc-orange-dark); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .82rem; }
.user__meta { display: flex; flex-direction: column; line-height: 1.2; }
.user__meta strong { font-size: .88rem; }
.user__meta .muted { font-size: .74rem; }
.imp-banner { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .55rem 1.4rem; background: var(--psc-orange); color: #fff; font-size: .9rem; font-weight: 600; }
.imp-banner .btn { background: #fff; color: var(--psc-orange-dark); }
.body { display: flex; flex: 1; min-height: 0; }
/* linke Sidebar (Firmen-Ebene) */
.sidebar { width: 232px; flex-shrink: 0; background: #fff; border-right: 1px solid var(--line); display: flex; flex-direction: column; position: sticky; top: 57px; height: calc(100vh - 57px); }
.sidebar__label { font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); padding: 1.1rem 1.3rem .4rem; }
.sidebar__nav { display: flex; flex-direction: column; gap: 3px; padding: .2rem .7rem; }
.navlink { display: flex; align-items: center; gap: .75rem; padding: .6rem .7rem; border-radius: var(--radius-sm); color: #5b5b5b; font-size: .92rem; font-weight: 600; }
.navlink__icon { display: flex; color: #9a9a9a; } .navlink__icon { display: flex; color: #9a9a9a; }
.navlink__label { flex: 1; } .navlink__label { flex: 1; }
.navlink__chev { color: #cfcfcf; } .navlink__chev { color: #cfcfcf; }
.navlink:hover { background: #f6f6f6; color: var(--text); text-decoration: none; } .navlink:hover { background: #f6f6f6; color: var(--text); text-decoration: none; }
.navlink:hover .navlink__icon { color: #6f6f6f; } .navlink:hover .navlink__icon { color: #6f6f6f; }
.navlink.active { background: var(--psc-orange-soft); color: var(--psc-orange-dark); } .navlink.active { background: var(--psc-orange-soft); color: var(--psc-orange-dark); }
.navlink.active .navlink__icon, .navlink.active .navlink__icon, .navlink.active .navlink__chev { color: var(--psc-orange); }
.navlink.active .navlink__chev { color: var(--psc-orange); }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; } .content { flex: 1; min-width: 0; padding: 1.8rem; }
.imp-banner { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .55rem 1.6rem; background: var(--psc-orange); color: #fff; font-size: .9rem; font-weight: 600; }
.imp-banner .btn { background: #fff; color: var(--psc-orange-dark); }
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: .85rem 1.6rem; background: #fff; border-bottom: 1px solid var(--line);
}
.topbar__ctx { display: flex; flex-direction: column; line-height: 1.2; }
.topbar__ctx .muted { font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; }
.topbar__user { display: flex; align-items: center; gap: 1.1rem; }
.user { display: flex; align-items: center; gap: .65rem; }
.user__avatar {
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
background: var(--psc-orange-soft); color: var(--psc-orange-dark);
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .85rem;
}
.user__meta { display: flex; flex-direction: column; line-height: 1.25; }
.user__meta strong { font-size: .92rem; }
.user__meta .muted { font-size: .76rem; }
.content { padding: 1.8rem; }
</style> </style>

View File

@ -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 &amp; 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">
<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> </tr>
</tbody> </tbody>
</table> </table>
</td>
</tr>
</template>
</tbody>
</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>