feat: cms feedback and customer management
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<private-view title="Customer Manager">
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="drawerCompanyActive = true" clickable>
|
||||
<v-list-item @click="openCreateCompany" 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" />
|
||||
@@ -37,7 +37,7 @@
|
||||
<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-button x-small @click="openCreateCompany">erstelle eine neue Firma</v-button>.
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
@@ -48,10 +48,10 @@
|
||||
<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-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditCompany">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<v-button primary @click="drawerEmployeeActive = true">
|
||||
<v-button primary @click="openCreateEmployee">
|
||||
Mitarbeiter hinzufügen
|
||||
</v-button>
|
||||
</div>
|
||||
@@ -78,113 +78,79 @@
|
||||
</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 -->
|
||||
<!-- Drawer: Company Form -->
|
||||
<v-drawer
|
||||
v-model="drawerCompanyActive"
|
||||
title="Neue Firma anlegen"
|
||||
:title="isEditingCompany ? 'Firma bearbeiten' : '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 v-if="drawerCompanyActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Firmenname</span>
|
||||
<v-input v-model="companyForm.name" placeholder="z.B. KLZ Cables" autofocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="createCompany">Firma speichern</v-button>
|
||||
<v-button primary block :loading="saving" @click="saveCompany">Speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
|
||||
<!-- Drawer: Create Employee -->
|
||||
<!-- Drawer: Employee Form -->
|
||||
<v-drawer
|
||||
v-model="drawerEmployeeActive"
|
||||
title="Neuen Mitarbeiter anlegen"
|
||||
icon="person_add"
|
||||
:title="isEditingEmployee ? 'Mitarbeiter bearbeiten' : 'Neuen Mitarbeiter anlegen'"
|
||||
icon="person"
|
||||
@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 v-if="drawerEmployeeActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Vorname</span>
|
||||
<v-input v-model="employeeForm.first_name" placeholder="Vorname" autofocus />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Nachname</span>
|
||||
<v-input v-model="employeeForm.last_name" placeholder="Nachname" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">E-Mail</span>
|
||||
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
|
||||
</div>
|
||||
|
||||
<v-divider v-if="isEditingEmployee" />
|
||||
|
||||
<div v-if="isEditingEmployee" class="field">
|
||||
<span class="label">Temporäres Passwort</span>
|
||||
<v-input v-model="employeeForm.temporary_password" readonly class="password-input" />
|
||||
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<v-button primary block :loading="saving" @click="saveEmployee">Daten speichern</v-button>
|
||||
|
||||
<template v-if="isEditingEmployee">
|
||||
<v-divider />
|
||||
<v-button
|
||||
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
|
||||
secondary
|
||||
block
|
||||
:loading="invitingId === employeeForm.id"
|
||||
@click="inviteUser(employeeForm)"
|
||||
>
|
||||
<v-icon name="send" left /> Zugangsdaten senden
|
||||
</v-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
@@ -192,7 +158,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
@@ -205,21 +171,25 @@ const saving = ref(false);
|
||||
const invitingId = ref<string | null>(null);
|
||||
const notice = ref<{ type: string; message: string } | null>(null);
|
||||
|
||||
// Drawers
|
||||
// Forms State
|
||||
const drawerCompanyActive = ref(false);
|
||||
const drawerEmployeeActive = ref(false);
|
||||
const drawerDetailActive = ref(false);
|
||||
const drawerItem = ref<any>(null);
|
||||
const isEditingCompany = ref(false);
|
||||
const companyForm = ref({ id: '', name: '' });
|
||||
|
||||
// Forms
|
||||
const newCompany = ref({ name: '' });
|
||||
const newEmployee = ref({ first_name: '', last_name: '', email: '' });
|
||||
const drawerEmployeeActive = ref(false);
|
||||
const isEditingEmployee = ref(false);
|
||||
const employeeForm = ref({
|
||||
id: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
temporary_password: ''
|
||||
});
|
||||
|
||||
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' },
|
||||
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
|
||||
];
|
||||
|
||||
async function fetchCompanies() {
|
||||
@@ -238,11 +208,7 @@ async function selectCompany(company: any) {
|
||||
try {
|
||||
const res = await api.get('/items/client_users', {
|
||||
params: {
|
||||
filter: {
|
||||
company: {
|
||||
_eq: company.id,
|
||||
},
|
||||
},
|
||||
filter: { company: { _eq: company.id } },
|
||||
fields: ['*'],
|
||||
sort: 'first_name',
|
||||
},
|
||||
@@ -253,15 +219,40 @@ async function selectCompany(company: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createCompany() {
|
||||
if (!newCompany.value.name) return;
|
||||
// Company Actions
|
||||
function openCreateCompany() {
|
||||
isEditingCompany.value = false;
|
||||
companyForm.value = { id: '', name: '' };
|
||||
drawerCompanyActive.value = true;
|
||||
}
|
||||
|
||||
async function openEditCompany() {
|
||||
if (!selectedCompany.value) return;
|
||||
companyForm.value = {
|
||||
id: selectedCompany.value.id,
|
||||
name: selectedCompany.value.name
|
||||
};
|
||||
isEditingCompany.value = true;
|
||||
await nextTick();
|
||||
drawerCompanyActive.value = true;
|
||||
}
|
||||
|
||||
async function saveCompany() {
|
||||
if (!companyForm.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 = '';
|
||||
if (isEditingCompany.value) {
|
||||
await api.patch(`/items/companies/${companyForm.value.id}`, { name: companyForm.value.name });
|
||||
notice.value = { type: 'success', message: 'Firma aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/companies', { name: companyForm.value.name });
|
||||
notice.value = { type: 'success', message: 'Firma angelegt!' };
|
||||
}
|
||||
drawerCompanyActive.value = false;
|
||||
await fetchCompanies();
|
||||
if (selectedCompany.value?.id === companyForm.value.id) {
|
||||
selectedCompany.value.name = companyForm.value.name;
|
||||
}
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: e.message };
|
||||
} finally {
|
||||
@@ -269,16 +260,46 @@ async function createCompany() {
|
||||
}
|
||||
}
|
||||
|
||||
async function createEmployee() {
|
||||
if (!newEmployee.value.email || !selectedCompany.value) return;
|
||||
// Employee Actions
|
||||
function openCreateEmployee() {
|
||||
isEditingEmployee.value = false;
|
||||
employeeForm.value = { id: '', first_name: '', last_name: '', email: '', temporary_password: '' };
|
||||
drawerEmployeeActive.value = true;
|
||||
}
|
||||
|
||||
async function openEditEmployee(item: any) {
|
||||
employeeForm.value = {
|
||||
id: item.id || '',
|
||||
first_name: item.first_name || '',
|
||||
last_name: item.last_name || '',
|
||||
email: item.email || '',
|
||||
temporary_password: item.temporary_password || ''
|
||||
};
|
||||
isEditingEmployee.value = true;
|
||||
await nextTick();
|
||||
drawerEmployeeActive.value = true;
|
||||
}
|
||||
|
||||
async function saveEmployee() {
|
||||
if (!employeeForm.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: '' };
|
||||
if (isEditingEmployee.value) {
|
||||
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
|
||||
first_name: employeeForm.value.first_name,
|
||||
last_name: employeeForm.value.last_name,
|
||||
email: employeeForm.value.email
|
||||
});
|
||||
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/client_users', {
|
||||
first_name: employeeForm.value.first_name,
|
||||
last_name: employeeForm.value.last_name,
|
||||
email: employeeForm.value.email,
|
||||
company: selectedCompany.value.id
|
||||
});
|
||||
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
|
||||
}
|
||||
drawerEmployeeActive.value = false;
|
||||
await selectCompany(selectedCompany.value);
|
||||
} catch (e: any) {
|
||||
@@ -291,61 +312,34 @@ async function createEmployee() {
|
||||
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. 📧`
|
||||
};
|
||||
|
||||
notice.value = { type: 'success', message: `Zugangsdaten für ${user.first_name} versendet. 📧` };
|
||||
await selectCompany(selectedCompany.value);
|
||||
if (drawerItem.value?.id === user.id) {
|
||||
drawerItem.value = employees.value.find(e => e.id === user.id);
|
||||
}
|
||||
if (drawerEmployeeActive.value && employeeForm.value.id === user.id) {
|
||||
const updated = employees.value.find(e => e.id === user.id);
|
||||
if (updated) {
|
||||
employeeForm.value.temporary_password = updated.temporary_password;
|
||||
}
|
||||
}
|
||||
} 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}`
|
||||
};
|
||||
notice.value = { type: 'danger', message: `Fehler: ${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);
|
||||
openEditEmployee(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');
|
||||
}
|
||||
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'
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -354,101 +348,30 @@ onMounted(() => {
|
||||
</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 {
|
||||
.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); }
|
||||
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
||||
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
||||
.field-note { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px; }
|
||||
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.password-input :deep(textarea) {
|
||||
font-family: var(--family-monospace);
|
||||
background: var(--theme--background-subdued);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
color: var(--theme--primary);
|
||||
font-weight: 800;
|
||||
color: var(--theme--primary) !important;
|
||||
background: var(--theme--background-subdued) !important;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user