feat: integrate cms

This commit is contained in:
2026-02-09 12:08:47 +01:00
parent 59d3e97ef0
commit a306d24f51
18 changed files with 4279 additions and 35 deletions

View File

@@ -0,0 +1,14 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'customer-manager',
name: 'Customer Manager',
icon: 'supervisor_account',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});

View File

@@ -0,0 +1,454 @@
<template>
<private-view title="Customer Manager">
<template #navigation>
<v-list nav>
<v-list-item @click="drawerCompanyActive = true" clickable>
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow text="Neue Firma anlegen" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
v-for="company in companies"
:key="company.id"
:active="selectedCompany?.id === company.id"
class="company-item"
clickable
@click="selectCompany(company)"
>
<v-list-item-icon><v-icon name="business" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="company.name" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<template #title-outer:after>
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
{{ notice.message }}
</v-notice>
</template>
<div class="content-wrapper">
<div v-if="!selectedCompany" class="empty-state">
<v-info title="Firmen auswählen" icon="business" center>
Wähle eine Firma in der Navigation aus oder
<v-button x-small @click="drawerCompanyActive = true">erstelle eine neue Firma</v-button>.
</v-info>
</div>
<template v-else>
<header class="header">
<div class="header-left">
<h1 class="title">{{ selectedCompany.name }}</h1>
<p class="subtitle">{{ employees.length }} Kunden-Mitarbeiter</p>
</div>
<div class="header-right">
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openCompanyDetails">
<v-icon name="edit" />
</v-button>
<v-button primary @click="drawerEmployeeActive = true">
Mitarbeiter hinzufügen
</v-button>
</div>
</header>
<v-table
:headers="tableHeaders"
:items="employees"
:loading="loading"
class="clickable-table"
fixed-header
@click:row="onRowClick"
>
<template #[`item.name`]="{ item }">
<div class="user-cell">
<v-avatar :name="item.first_name" x-small />
<span class="user-name">{{ item.first_name }} {{ item.last_name }}</span>
</div>
</template>
<template #[`item.last_invited`]="{ item }">
<span v-if="item.last_invited" class="status-date">
{{ formatDate(item.last_invited) }}
</span>
<v-chip v-else x-small>Noch nie</v-chip>
</template>
<template #[`item.actions`]="{ item }">
<div class="action-buttons">
<v-button
v-tooltip.bottom="'Quick-View'"
secondary
rounded
icon
@click.stop="openQuickView(item)"
>
<v-icon name="visibility" />
</v-button>
</div>
</template>
</v-table>
</template>
</div>
<!-- Drawer: Create Company -->
<v-drawer
v-model="drawerCompanyActive"
title="Neue Firma anlegen"
icon="business"
@cancel="drawerCompanyActive = false"
>
<div class="drawer-content">
<v-field-template label="Firmenname" required>
<v-input v-model="newCompany.name" placeholder="z.B. KLZ Cables" autofocus />
</v-field-template>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="createCompany">Firma speichern</v-button>
</div>
</div>
</v-drawer>
<!-- Drawer: Create Employee -->
<v-drawer
v-model="drawerEmployeeActive"
title="Neuen Mitarbeiter anlegen"
icon="person_add"
@cancel="drawerEmployeeActive = false"
>
<div class="drawer-content">
<v-field-template label="Vorname" required>
<v-input v-model="newEmployee.first_name" placeholder="Vorname" autofocus />
</v-field-template>
<v-field-template label="Nachname" required>
<v-input v-model="newEmployee.last_name" placeholder="Nachname" />
</v-field-template>
<v-field-template label="E-Mail" required>
<v-input v-model="newEmployee.email" placeholder="E-Mail Adresse" type="email" />
</v-field-template>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="createEmployee">Mitarbeiter speichern</v-button>
</div>
</div>
</v-drawer>
<!-- Drawer: Employee Detail Quick-View -->
<v-drawer
v-model="drawerDetailActive"
:title="drawerItem ? `${drawerItem.first_name} ${drawerItem.last_name}` : 'Mitarbeiter'"
icon="person"
@cancel="drawerDetailActive = false"
>
<div v-if="drawerItem" class="drawer-content">
<v-list>
<v-list-item>
<v-list-item-content>
<v-list-item-label>Name</v-list-item-label>
<v-list-item-hint>{{ drawerItem.first_name }} {{ drawerItem.last_name }}</v-list-item-hint>
</v-list-item-content>
</v-list-item>
<v-list-item divider>
<v-list-item-content>
<v-list-item-label>E-Mail</v-list-item-label>
<v-list-item-hint>{{ drawerItem.email }}</v-list-item-hint>
</v-list-item-content>
</v-list-item>
<v-list-item divider>
<v-list-item-content>
<v-list-item-label>Temporäres Passwort</v-list-item-label>
<v-list-item-hint class="password-text">{{ drawerItem.temporary_password || 'Noch nicht generiert' }}</v-list-item-hint>
</v-list-item-content>
</v-list-item>
<v-list-item divider>
<v-list-item-content>
<v-list-item-label>Zuletzt eingeladen</v-list-item-label>
<v-list-item-hint>{{ drawerItem.last_invited ? formatDate(drawerItem.last_invited) : 'Nie' }}</v-list-item-hint>
</v-list-item-content>
</v-list-item>
</v-list>
<div class="drawer-actions">
<v-button
primary
block
:loading="invitingId === drawerItem.id"
@click="inviteUser(drawerItem)"
>
Zugangsdaten generieren & senden
</v-button>
<v-button secondary block @click="openFullProfile(drawerItem)">
Vollständiges Profil öffnen
</v-button>
</div>
</div>
</v-drawer>
</private-view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
const companies = ref<any[]>([]);
const selectedCompany = ref<any>(null);
const employees = ref<any[]>([]);
const loading = ref(false);
const saving = ref(false);
const invitingId = ref<string | null>(null);
const notice = ref<{ type: string; message: string } | null>(null);
// Drawers
const drawerCompanyActive = ref(false);
const drawerEmployeeActive = ref(false);
const drawerDetailActive = ref(false);
const drawerItem = ref<any>(null);
// Forms
const newCompany = ref({ name: '' });
const newEmployee = ref({ first_name: '', last_name: '', email: '' });
const tableHeaders = [
{ text: 'Name', value: 'name', sortable: true },
{ text: 'E-Mail', value: 'email', sortable: true },
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true },
{ text: '', value: 'actions', width: 60, align: 'right' },
];
async function fetchCompanies() {
const res = await api.get('/items/companies', {
params: {
fields: ['id', 'name'],
sort: 'name',
},
});
companies.value = res.data.data;
}
async function selectCompany(company: any) {
selectedCompany.value = company;
loading.value = true;
try {
const res = await api.get('/items/client_users', {
params: {
filter: {
company: {
_eq: company.id,
},
},
fields: ['*'],
sort: 'first_name',
},
});
employees.value = res.data.data;
} finally {
loading.value = false;
}
}
async function createCompany() {
if (!newCompany.value.name) return;
saving.value = true;
try {
await api.post('/items/companies', newCompany.value);
notice.value = { type: 'success', message: 'Firma erfolgreich angelegt!' };
newCompany.value.name = '';
drawerCompanyActive.value = false;
await fetchCompanies();
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
saving.value = false;
}
}
async function createEmployee() {
if (!newEmployee.value.email || !selectedCompany.value) return;
saving.value = true;
try {
await api.post('/items/client_users', {
...newEmployee.value,
company: selectedCompany.value.id
});
notice.value = { type: 'success', message: 'Mitarbeiter erfolgreich angelegt!' };
newEmployee.value = { first_name: '', last_name: '', email: '' };
drawerEmployeeActive.value = false;
await selectCompany(selectedCompany.value);
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
saving.value = false;
}
}
async function inviteUser(user: any) {
invitingId.value = user.id;
try {
// Flow ID from Step 2450
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
notice.value = {
type: 'success',
message: `Zugangsdaten für ${user.first_name} wurden versendet. 📧`
};
await selectCompany(selectedCompany.value);
if (drawerItem.value?.id === user.id) {
drawerItem.value = employees.value.find(e => e.id === user.id);
}
} catch (e: any) {
console.error('Failed to trigger invite flow', e);
notice.value = {
type: 'danger',
message: `Fehler beim Senden: ${e.response?.data?.errors?.[0]?.message || e.message}`
};
} finally {
invitingId.value = null;
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function onRowClick(event: any) {
const item = event?.item || event;
if (item && item.id) {
openQuickView(item);
}
}
function openQuickView(item: any) {
drawerItem.value = item;
drawerDetailActive.value = true;
}
function openFullProfile(item: any) {
if (item && item.id) {
window.open(`/admin/content/client_users/${item.id}`, '_blank');
}
}
function openCompanyDetails() {
if (selectedCompany.value) {
window.open(`/admin/content/companies/${selectedCompany.value.id}`, '_blank');
}
}
onMounted(() => {
fetchCompanies();
});
</script>
<style scoped>
.content-wrapper {
padding: 32px;
height: 100%;
display: flex;
flex-direction: column;
}
.company-item {
cursor: pointer;
}
.header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.header-right {
display: flex;
gap: 12px;
}
.title {
font-size: 24px;
font-weight: 800;
margin-bottom: 4px;
}
.subtitle {
color: var(--theme--foreground-subdued);
font-size: 14px;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.user-cell {
display: flex;
align-items: center;
gap: 12px;
}
.user-name {
font-weight: 600;
}
.status-date {
font-size: 12px;
color: var(--theme--foreground-subdued);
}
.action-buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.drawer-content {
padding: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.drawer-actions {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.password-text {
font-family: var(--family-monospace);
background: var(--theme--background-subdued);
padding: 4px 8px;
border-radius: 4px;
font-weight: 600;
color: var(--theme--primary);
}
.clickable-table :deep(tbody tr) {
cursor: pointer;
transition: background-color 0.2s ease;
}
.clickable-table :deep(tbody tr:hover) {
background-color: var(--theme--background-subdued) !important;
}
:deep(.v-list-item) {
cursor: pointer !important;
}
</style>