feat: integrate cms
This commit is contained in:
BIN
packages/cms-infra/database/data.db
Normal file
BIN
packages/cms-infra/database/data.db
Normal file
Binary file not shown.
39
packages/cms-infra/docker-compose.yml
Normal file
39
packages/cms-infra/docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
infra-cms:
|
||||||
|
image: directus/directus:11
|
||||||
|
ports:
|
||||||
|
- "8059:8055"
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- infra
|
||||||
|
environment:
|
||||||
|
KEY: "infra-cms-key"
|
||||||
|
SECRET: "infra-cms-secret"
|
||||||
|
ADMIN_EMAIL: "marc@mintel.me"
|
||||||
|
ADMIN_PASSWORD: "Tim300493."
|
||||||
|
DB_CLIENT: "sqlite3"
|
||||||
|
DB_FILENAME: "/directus/database/data.db"
|
||||||
|
WEBSOCKETS_ENABLED: "true"
|
||||||
|
EMAIL_TRANSPORT: "smtp"
|
||||||
|
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
|
||||||
|
EMAIL_SMTP_PORT: "587"
|
||||||
|
EMAIL_SMTP_USER: "postmaster@mg.mintel.me"
|
||||||
|
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
|
||||||
|
EMAIL_SMTP_SECURE: "false"
|
||||||
|
EMAIL_FROM: "postmaster@mg.mintel.me"
|
||||||
|
volumes:
|
||||||
|
- ./database:/directus/database
|
||||||
|
- ./uploads:/directus/uploads
|
||||||
|
- ./schema:/directus/schema
|
||||||
|
- ./extensions:/directus/extensions
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.infra-cms.rule=Host(`cms.localhost`)"
|
||||||
|
- "traefik.http.services.infra-cms.loadbalancer.server.port=8055"
|
||||||
|
- "traefik.docker.network=infra"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: mintel-infra-cms-internal
|
||||||
|
infra:
|
||||||
|
external: true
|
||||||
1
packages/cms-infra/extensions/customer-manager/index.js
Normal file
1
packages/cms-infra/extensions/customer-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
29
packages/cms-infra/extensions/customer-manager/package.json
Normal file
29
packages/cms-infra/extensions/customer-manager/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "customer-manager",
|
||||||
|
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||||
|
"icon": "supervisor_account",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-extension-module"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "index.js",
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"host": "*",
|
||||||
|
"name": "Customer Manager"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"vue": "^3.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "feedback-commander",
|
||||||
|
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||||
|
"icon": "view_kanban",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-extension-module"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"index.js"
|
||||||
|
],
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "index.js",
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"host": "*",
|
||||||
|
"name": "Feedback Commander"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"vue": "^3.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/cms-infra/package.json
Normal file
11
packages/cms-infra/package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/cms-infra",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"up": "docker compose up -d",
|
||||||
|
"down": "docker compose down",
|
||||||
|
"logs": "docker compose logs -f"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/cms-infra/uploads/directus-health-file
Normal file
1
packages/cms-infra/uploads/directus-health-file
Normal file
@@ -0,0 +1 @@
|
|||||||
|
xmKX5
|
||||||
1
packages/customer-manager/index.js
Normal file
1
packages/customer-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
29
packages/customer-manager/package.json
Normal file
29
packages/customer-manager/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "customer-manager",
|
||||||
|
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||||
|
"icon": "supervisor_account",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-extension-module"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "index.js",
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"host": "*",
|
||||||
|
"name": "Customer Manager"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"vue": "^3.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/customer-manager/src/index.ts
Normal file
14
packages/customer-manager/src/index.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
454
packages/customer-manager/src/module.vue
Normal file
454
packages/customer-manager/src/module.vue
Normal 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>
|
||||||
29
packages/feedback-commander/package.json
Normal file
29
packages/feedback-commander/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/extension-feedback-commander",
|
||||||
|
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||||
|
"icon": "view_kanban",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-extension-module"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"host": "*",
|
||||||
|
"name": "Feedback Commander"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"vue": "^3.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/feedback-commander/src/index.ts
Normal file
14
packages/feedback-commander/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineModule } from '@directus/extensions-sdk';
|
||||||
|
import ModuleComponent from './module.vue';
|
||||||
|
|
||||||
|
export default defineModule({
|
||||||
|
id: 'feedback-commander',
|
||||||
|
name: 'Feedback Commander',
|
||||||
|
icon: 'view_kanban',
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: ModuleComponent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
723
packages/feedback-commander/src/module.vue
Normal file
723
packages/feedback-commander/src/module.vue
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
<template>
|
||||||
|
<private-view title="Feedback Commander">
|
||||||
|
<template #headline>
|
||||||
|
<v-breadcrumb :items="[{ name: 'Feedback', to: '/feedback-commander' }]" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #title-outer:after>
|
||||||
|
<v-chip v-if="loading" label color="blue" small>Loading...</v-chip>
|
||||||
|
<v-chip v-else-if="fetchError" label color="red" small>Fetch Error</v-chip>
|
||||||
|
<v-chip v-else label color="green" small>{{ items.length }} Items</v-chip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #navigation>
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<v-text-overflow text="Websites" class="header-text" />
|
||||||
|
</div>
|
||||||
|
<v-list nav>
|
||||||
|
<v-list-item
|
||||||
|
:active="currentProject === 'all'"
|
||||||
|
@click="currentProject = 'all'"
|
||||||
|
clickable
|
||||||
|
>
|
||||||
|
<v-list-item-icon><v-icon name="language" /></v-list-item-icon>
|
||||||
|
<v-list-item-content><v-text-overflow text="All Projects" /></v-list-item-content>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-for="project in projects"
|
||||||
|
:key="project"
|
||||||
|
:active="currentProject === project"
|
||||||
|
@click="currentProject = project"
|
||||||
|
clickable
|
||||||
|
>
|
||||||
|
<v-list-item-icon><v-icon name="public" color="var(--primary)" /></v-list-item-icon>
|
||||||
|
<v-list-item-content><v-text-overflow :text="project || 'Unknown'" /></v-list-item-content>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="feedback-container">
|
||||||
|
<div v-if="!items.length && !loading && !fetchError" class="empty-state">
|
||||||
|
<v-info icon="inbox" title="Clean Inbox" center>
|
||||||
|
All feedback has been processed. Great job!
|
||||||
|
</v-info>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="fetchError" class="empty-state">
|
||||||
|
<v-info icon="error" title="Fetch Failed" :description="fetchError" center />
|
||||||
|
<v-button @click="fetchData" secondary small>Retry</v-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="operational-layout" v-else-if="items.length">
|
||||||
|
<!-- Detailed Triage Lane -->
|
||||||
|
<aside class="triage-lane">
|
||||||
|
<div class="lane-header">
|
||||||
|
<v-select
|
||||||
|
v-model="currentStatusFilter"
|
||||||
|
:items="statusOptions"
|
||||||
|
small
|
||||||
|
placeholder="Status Filter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="lane-content scrollbar">
|
||||||
|
<TransitionGroup name="list">
|
||||||
|
<div
|
||||||
|
v-for="item in filteredItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="feedback-card"
|
||||||
|
:class="{ active: selectedItem?.id === item.id }"
|
||||||
|
@click="selectItem(item)"
|
||||||
|
>
|
||||||
|
<div class="card-status-bar" :style="{ background: getStatusColor(item.status || 'open') }"></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<header class="card-header">
|
||||||
|
<span class="card-user">{{ item.user_name }}</span>
|
||||||
|
<span class="card-date">{{ formatDate(item.date_created || item.id) }}</span>
|
||||||
|
</header>
|
||||||
|
<div class="card-text">{{ item.text }}</div>
|
||||||
|
<footer class="card-footer">
|
||||||
|
<div class="meta-tags">
|
||||||
|
<v-chip x-small outline>{{ item.project }}</v-chip>
|
||||||
|
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
|
||||||
|
</div>
|
||||||
|
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Elaborated Master-Detail Desk -->
|
||||||
|
<main class="processing-desk scrollbar">
|
||||||
|
<Transition name="fade" mode="out-in">
|
||||||
|
<div v-if="selectedItem" :key="selectedItem.id" class="desk-content">
|
||||||
|
<header class="desk-header">
|
||||||
|
<div class="headline-group">
|
||||||
|
<div class="status-indicator">
|
||||||
|
<div class="status-dot" :style="{ background: getStatusColor(selectedItem.status || 'open') }"></div>
|
||||||
|
<span class="status-text">{{ capitalize(selectedItem.status || 'open') }}</span>
|
||||||
|
</div>
|
||||||
|
<h2>{{ selectedItem.user_name }}'s Submission</h2>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<v-button primary @click="openDeepLink(selectedItem)">
|
||||||
|
<v-icon name="open_in_new" left /> Open & Highlight
|
||||||
|
</v-button>
|
||||||
|
<v-select
|
||||||
|
v-model="selectedItem.status"
|
||||||
|
:items="statuses"
|
||||||
|
inline
|
||||||
|
@update:model-value="updateStatus"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="desk-grid">
|
||||||
|
<!-- Message Container -->
|
||||||
|
<div class="main-column">
|
||||||
|
<v-card class="content-card">
|
||||||
|
<v-card-title>
|
||||||
|
<v-icon name="format_quote" left />
|
||||||
|
Feedback Content
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="feedback-body">
|
||||||
|
<div v-if="selectedItem.screenshot" class="visual-proof">
|
||||||
|
<label class="proof-label"><v-icon name="photo" x-small /> Element Snapshot</label>
|
||||||
|
<img :src="getAssetUrl(selectedItem.screenshot)" class="screenshot-img" />
|
||||||
|
</div>
|
||||||
|
<div class="main-text">{{ selectedItem.text }}</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<section class="reply-section">
|
||||||
|
<div class="section-divider">
|
||||||
|
<v-divider />
|
||||||
|
<span class="divider-label">Internal Communication</span>
|
||||||
|
<v-divider />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="thread">
|
||||||
|
<TransitionGroup name="thread-list">
|
||||||
|
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
|
||||||
|
<header class="reply-header">
|
||||||
|
<span class="reply-user">{{ reply.user_name }}</span>
|
||||||
|
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
|
||||||
|
</header>
|
||||||
|
<div class="reply-text">{{ reply.text }}</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
<div v-if="!comments.length" class="empty-state-mini">
|
||||||
|
<v-icon name="auto_awesome" small /> No replies yet. Start the thread.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
<v-textarea v-model="replyText" placeholder="Compose internal response..." auto-grow />
|
||||||
|
<div class="composer-actions">
|
||||||
|
<v-button secondary :loading="sending" @click="sendReply">Post Reply</v-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Technical Sidebar -->
|
||||||
|
<aside class="meta-column">
|
||||||
|
<v-card class="meta-card">
|
||||||
|
<v-card-title>Context</v-card-title>
|
||||||
|
<v-card-text class="meta-list">
|
||||||
|
<div class="meta-item">
|
||||||
|
<label><v-icon name="public" x-small /> Website</label>
|
||||||
|
<strong>{{ selectedItem.project }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<label><v-icon name="link" x-small /> Source Path</label>
|
||||||
|
<span class="truncate-path" :title="selectedItem.url">{{ formatUrl(selectedItem.url) }}</span>
|
||||||
|
<v-button icon small @click="openExternal(selectedItem.url)"><v-icon name="launch" /></v-button>
|
||||||
|
</div>
|
||||||
|
<v-divider />
|
||||||
|
<div class="meta-item">
|
||||||
|
<label><v-icon name="layers" x-small /> Element Trace</label>
|
||||||
|
<code class="trace-code">{{ selectedItem.selector || 'Body' }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<label><v-icon name="location_searching" x-small /> Precise Mark</label>
|
||||||
|
<span class="coords">X: {{ Math.round(selectedItem.x) }}px / Y: {{ Math.round(selectedItem.y) }}px</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<label><v-icon name="fingerprint" x-small /> Reference ID</label>
|
||||||
|
<code class="id-code">{{ selectedItem.id }}</code>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<div class="help-box">
|
||||||
|
<v-icon name="help_outline" x-small />
|
||||||
|
<span>Click "Open & Highlight" to jump directly to this element on the live site.</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-selection-desk">
|
||||||
|
<v-info icon="touch_app" title="Select Feedback" center>
|
||||||
|
Choose an entry from the triage list to view details and process.
|
||||||
|
</v-info>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</private-view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const items = ref([]);
|
||||||
|
const comments = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const fetchError = ref(null);
|
||||||
|
const sending = ref(false);
|
||||||
|
const selectedItem = ref(null);
|
||||||
|
const currentProject = ref('all');
|
||||||
|
const currentStatusFilter = ref('open');
|
||||||
|
const replyText = ref('');
|
||||||
|
|
||||||
|
const statuses = [
|
||||||
|
{ text: 'Open', value: 'open', icon: 'warning', color: '#E91E63' },
|
||||||
|
{ text: 'In Progress', value: 'in_progress', icon: 'play_arrow', color: '#2196F3' },
|
||||||
|
{ text: 'Resolved', value: 'resolved', icon: 'check_circle', color: '#4CAF50' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ text: 'All Statuses', value: 'all' },
|
||||||
|
...statuses
|
||||||
|
];
|
||||||
|
|
||||||
|
const projects = computed(() => {
|
||||||
|
const projSet = new Set(items.value.map(i => i.project).filter(Boolean));
|
||||||
|
return Array.from(projSet).sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
return items.value.filter(item => {
|
||||||
|
const matchProject = currentProject.value === 'all' || item.project === currentProject.value;
|
||||||
|
const status = item.status || 'open';
|
||||||
|
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
|
||||||
|
return matchProject && matchStatus;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
loading.value = true;
|
||||||
|
fetchError.value = null;
|
||||||
|
try {
|
||||||
|
const response = await api.get('/items/visual_feedback', {
|
||||||
|
params: {
|
||||||
|
sort: '-date_created,-id',
|
||||||
|
limit: 300
|
||||||
|
}
|
||||||
|
});
|
||||||
|
items.value = response.data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
fetchError.value = e.message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectItem(item) {
|
||||||
|
selectedItem.value = null;
|
||||||
|
setTimeout(async () => {
|
||||||
|
selectedItem.value = item;
|
||||||
|
comments.value = [];
|
||||||
|
try {
|
||||||
|
const response = await api.get('/items/visual_feedback_comments', {
|
||||||
|
params: {
|
||||||
|
filter: { feedback_id: { _eq: item.id } },
|
||||||
|
sort: '-date_created,-id'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
comments.value = response.data.data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(val) {
|
||||||
|
if (!selectedItem.value) return;
|
||||||
|
try {
|
||||||
|
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
|
||||||
|
status: val
|
||||||
|
});
|
||||||
|
fetchData();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReply() {
|
||||||
|
if (!replyText.value.trim() || !selectedItem.value) return;
|
||||||
|
sending.value = true;
|
||||||
|
try {
|
||||||
|
const response = await api.post('/items/visual_feedback_comments', {
|
||||||
|
feedback_id: selectedItem.value.id,
|
||||||
|
user_name: 'Operator',
|
||||||
|
text: replyText.value
|
||||||
|
});
|
||||||
|
comments.value.unshift(response.data.data);
|
||||||
|
replyText.value = '';
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
sending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr || typeof dateStr === 'number') return 'Legacy';
|
||||||
|
return new Date(dateStr).toLocaleDateString() + ' ' + new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
return url.replace(/^https?:\/\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function capitalize(s) {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDeepLinkUrl(item) {
|
||||||
|
if (!item || !item.url) return '';
|
||||||
|
try {
|
||||||
|
const url = new URL(item.url);
|
||||||
|
url.searchParams.set('fb_id', item.id);
|
||||||
|
return url.toString();
|
||||||
|
} catch (e) {
|
||||||
|
return item.url + '?fb_id=' + item.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDeepLink(item) {
|
||||||
|
const url = getDeepLinkUrl(item);
|
||||||
|
if (url) window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExternal(url) {
|
||||||
|
if (url) window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssetUrl(id) {
|
||||||
|
if (!id) return '';
|
||||||
|
return `/assets/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status) {
|
||||||
|
const s = statuses.find(st => st.value === status);
|
||||||
|
return s ? s.color : 'var(--foreground-subdued)';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feedback-container {
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--background-subdued);
|
||||||
|
}
|
||||||
|
|
||||||
|
.operational-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Triage Lane Polish */
|
||||||
|
.triage-lane {
|
||||||
|
width: 360px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--background-normal);
|
||||||
|
border-right: 1px solid var(--border-normal);
|
||||||
|
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lane-header {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--background-normal);
|
||||||
|
border-bottom: 1px solid var(--border-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lane-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-card {
|
||||||
|
background: var(--background-normal);
|
||||||
|
border: 1px solid var(--border-subdued);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-card:hover {
|
||||||
|
border-color: var(--border-normal);
|
||||||
|
background: var(--background-subdued);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-card.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--background-accent);
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-status-bar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-user { font-weight: bold; color: var(--foreground-normal); }
|
||||||
|
.card-date { color: var(--foreground-subdued); }
|
||||||
|
|
||||||
|
.card-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--foreground-normal);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Processing Desk Refinement */
|
||||||
|
.processing-desk {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-content {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
border-bottom: 2px solid var(--border-normal);
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--foreground-subdued);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text { letter-spacing: 0.5px; }
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desk-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 300px;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-body {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--foreground-normal);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-proof {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--foreground-subdued);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-normal);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
background: var(--background-subdued);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-section {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--foreground-subdued);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-bubble {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--background-normal);
|
||||||
|
border: 1px solid var(--border-subdued);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-user { font-weight: 800; color: var(--primary); }
|
||||||
|
.reply-date { color: var(--foreground-subdued); }
|
||||||
|
|
||||||
|
.reply-text { font-size: 14px; line-height: 1.5; }
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
background: var(--background-normal);
|
||||||
|
border: 1px solid var(--border-normal);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--foreground-subdued);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncate-path {
|
||||||
|
color: var(--primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-code, .id-code {
|
||||||
|
background: var(--background-subdued);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coords { font-weight: bold; font-family: var(--family-monospace); }
|
||||||
|
|
||||||
|
.help-box {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(var(--primary-rgb), 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-selection-desk {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-mini {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--foreground-subdued);
|
||||||
|
background: var(--background-subdued);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed var(--border-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
|
||||||
|
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-20px); }
|
||||||
|
|
||||||
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||||
|
.fade-enter-from { opacity: 0; transform: translateY(10px); }
|
||||||
|
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
|
||||||
|
|
||||||
|
.thread-list-enter-active { transition: all 0.4s ease; transform-origin: top; }
|
||||||
|
.thread-list-enter-from { opacity: 0; transform: scaleY(0.9); }
|
||||||
|
|
||||||
|
.scrollbar::-webkit-scrollbar { width: 6px; }
|
||||||
|
.scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.scrollbar::-webkit-scrollbar-thumb { background: var(--border-subdued); border-radius: 3px; }
|
||||||
|
.scrollbar::-webkit-scrollbar-thumb:hover { background: var(--border-normal); }
|
||||||
|
</style>
|
||||||
@@ -12,9 +12,11 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let identity = "Guest";
|
let identity = "Guest";
|
||||||
|
let company = null;
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(session.value);
|
const payload = JSON.parse(session.value);
|
||||||
identity = payload.identity || "Guest";
|
identity = payload.identity || "Guest";
|
||||||
|
company = payload.company || null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Old format probably just the password
|
// Old format probably just the password
|
||||||
}
|
}
|
||||||
@@ -22,5 +24,6 @@ export async function GET(req: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
identity: identity,
|
identity: identity,
|
||||||
|
company: company,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||||
|
|
||||||
let userIdentity = "";
|
let userIdentity = "";
|
||||||
|
let userCompany: any = null;
|
||||||
|
|
||||||
// 1. Check Global Admin (from ENV)
|
// 1. Check Global Admin (from ENV)
|
||||||
if (
|
if (
|
||||||
@@ -43,8 +44,40 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
else if (!email && password === expectedCode) {
|
else if (!email && password === expectedCode) {
|
||||||
userIdentity = "Guest";
|
userIdentity = "Guest";
|
||||||
}
|
}
|
||||||
// 3. Check Directus if email is provided
|
// 3. Check Lightweight Client Users (dedicated collection)
|
||||||
if (email && password && process.env.DIRECTUS_URL) {
|
if (email && password && process.env.INFRA_DIRECTUS_URL) {
|
||||||
|
try {
|
||||||
|
const clientUsersRes = await fetch(
|
||||||
|
`${process.env.INFRA_DIRECTUS_URL}/items/client_users?filter[email][_eq]=${encodeURIComponent(
|
||||||
|
email
|
||||||
|
)}&fields=*,company.*`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.INFRA_DIRECTUS_TOKEN}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clientUsersRes.ok) {
|
||||||
|
const { data: users } = await clientUsersRes.json();
|
||||||
|
const clientUser = users[0];
|
||||||
|
|
||||||
|
// ⚠️ NOTE: Plain text check for demo/dev, should use argon2 in production
|
||||||
|
if (clientUser && clientUser.password === password) {
|
||||||
|
userIdentity = clientUser.first_name || clientUser.email;
|
||||||
|
userCompany = {
|
||||||
|
id: clientUser.company?.id,
|
||||||
|
name: clientUser.company?.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Client User Auth Error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback to Directus Staff Auth if still not identified
|
||||||
|
if (!userIdentity && email && password && process.env.DIRECTUS_URL) {
|
||||||
try {
|
try {
|
||||||
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
|
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -56,14 +89,21 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
const { data } = await loginRes.json();
|
const { data } = await loginRes.json();
|
||||||
const accessToken = data.access_token;
|
const accessToken = data.access_token;
|
||||||
|
|
||||||
// Fetch user info to get a nice display name
|
// Fetch user info with company depth
|
||||||
const userRes = await fetch(`${process.env.DIRECTUS_URL}/users/me`, {
|
const userRes = await fetch(
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
`${process.env.DIRECTUS_URL}/users/me?fields=*,company.*`,
|
||||||
});
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (userRes.ok) {
|
if (userRes.ok) {
|
||||||
const { data: user } = await userRes.json();
|
const { data: user } = await userRes.json();
|
||||||
userIdentity = user.first_name || user.email;
|
userIdentity = user.first_name || user.email;
|
||||||
|
userCompany = {
|
||||||
|
id: user.company?.id,
|
||||||
|
name: user.company?.name,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -76,6 +116,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
// Store identity in the cookie (simplified for now, ideally signed)
|
// Store identity in the cookie (simplified for now, ideally signed)
|
||||||
const sessionValue = JSON.stringify({
|
const sessionValue = JSON.stringify({
|
||||||
identity: userIdentity,
|
identity: userIdentity,
|
||||||
|
company: userCompany,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
2883
pnpm-lock.yaml
generated
2883
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user