chore: remove Directus CMS and related dependencies
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m19s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m5s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m26s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m19s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m5s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m26s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Image Processor (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
This commit is contained in:
@@ -15,11 +15,12 @@
|
|||||||
"pagespeed:test": "mintel pagespeed"
|
"pagespeed:test": "mintel pagespeed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mintel/image-processor": "workspace:*",
|
||||||
|
"@mintel/next-observability": "workspace:*",
|
||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
"@mintel/observability": "workspace:*",
|
"@mintel/observability": "workspace:*",
|
||||||
"@mintel/next-observability": "workspace:*",
|
|
||||||
"@mintel/image-processor": "workspace:*",
|
|
||||||
"@sentry/nextjs": "10.38.0",
|
"@sentry/nextjs": "10.38.0",
|
||||||
|
"@tensorflow/tfjs-backend-wasm": "^4.22.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-intl": "^4.8.2",
|
"next-intl": "^4.8.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- infra
|
||||||
environment:
|
|
||||||
- DIRECTUS_URL=${DIRECTUS_URL:-http://directus:8055}
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
ports:
|
ports:
|
||||||
@@ -24,60 +22,6 @@ services:
|
|||||||
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
|
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
|
||||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||||
|
|
||||||
directus:
|
|
||||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 5s
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- infra
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
KEY: ${DIRECTUS_KEY:-mintel-key}
|
|
||||||
SECRET: ${DIRECTUS_SECRET:-mintel-secret}
|
|
||||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
|
|
||||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
|
|
||||||
DB_CLIENT: 'pg'
|
|
||||||
DB_HOST: 'at-mintel-directus-db'
|
|
||||||
DB_PORT: '5432'
|
|
||||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
|
||||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
|
||||||
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
|
|
||||||
WEBSOCKETS_ENABLED: 'true'
|
|
||||||
PUBLIC_URL: ${DIRECTUS_URL:-http://localhost:8055}
|
|
||||||
ports:
|
|
||||||
- "8055:8055"
|
|
||||||
volumes:
|
|
||||||
- ./directus/uploads:/directus/uploads
|
|
||||||
- ./directus/extensions:/directus/extensions
|
|
||||||
- ./directus/schema:/directus/schema
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
|
||||||
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
|
||||||
- "caddy=http://${DIRECTUS_HOST:-cms.at.localhost}"
|
|
||||||
- "caddy.reverse_proxy={{upstreams 8055}}"
|
|
||||||
|
|
||||||
at-mintel-directus-db:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
restart: always
|
|
||||||
networks:
|
|
||||||
- infra
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
|
||||||
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
|
||||||
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
|
|
||||||
volumes:
|
|
||||||
- directus-db-data:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
infra:
|
infra:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
|
||||||
directus-db-data:
|
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -10,16 +10,6 @@
|
|||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"version-packages": "changeset version",
|
"version-packages": "changeset version",
|
||||||
"sync-versions": "tsx scripts/sync-versions.ts --",
|
"sync-versions": "tsx scripts/sync-versions.ts --",
|
||||||
"cms:dev": "pnpm --filter @mintel/cms-infra dev",
|
|
||||||
"cms:up": "pnpm --filter @mintel/cms-infra up",
|
|
||||||
"cms:down": "pnpm --filter @mintel/cms-infra down",
|
|
||||||
"cms:logs": "pnpm --filter @mintel/cms-infra logs",
|
|
||||||
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
|
|
||||||
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
|
||||||
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
|
|
||||||
"cms:sync:push": "./scripts/sync-directus.sh push infra",
|
|
||||||
"cms:sync:pull": "./scripts/sync-directus.sh pull infra",
|
|
||||||
"build:extensions": "./scripts/sync-extensions.sh",
|
|
||||||
"release": "pnpm build && changeset publish",
|
"release": "pnpm build && changeset publish",
|
||||||
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "acquisition-manager",
|
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
|
||||||
"icon": "extension",
|
|
||||||
"version": "1.8.21",
|
|
||||||
"type": "module",
|
|
||||||
"keywords": [
|
|
||||||
"directus",
|
|
||||||
"directus-extension",
|
|
||||||
"directus-extension-module"
|
|
||||||
],
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "module",
|
|
||||||
"path": "dist/index.js",
|
|
||||||
"source": "src/index.ts",
|
|
||||||
"host": "app",
|
|
||||||
"name": "acquisition manager"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "directus-extension build",
|
|
||||||
"dev": "directus-extension build -w"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mintel/directus-extension-toolkit": "workspace:*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { defineModule } from "@directus/extensions-sdk";
|
|
||||||
import ModuleComponent from "./module.vue";
|
|
||||||
|
|
||||||
export default defineModule({
|
|
||||||
id: "acquisition-manager",
|
|
||||||
name: "Acquisition",
|
|
||||||
icon: "auto_awesome",
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: "",
|
|
||||||
component: ModuleComponent,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ":id",
|
|
||||||
component: ModuleComponent,
|
|
||||||
props: true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
<template>
|
|
||||||
<MintelManagerLayout
|
|
||||||
title="Acquisition Manager"
|
|
||||||
:item-title="getCompanyName(selectedLead) || 'Lead wählen'"
|
|
||||||
:is-empty="!selectedLead"
|
|
||||||
empty-title="Lead auswählen"
|
|
||||||
empty-icon="auto_awesome"
|
|
||||||
:notice="notice"
|
|
||||||
@close-notice="notice = null"
|
|
||||||
>
|
|
||||||
<template #navigation>
|
|
||||||
<v-list nav>
|
|
||||||
<v-list-item @click="openCreateDrawer" 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="Neuen Lead anlegen" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-list-item
|
|
||||||
v-for="lead in leads"
|
|
||||||
:key="lead.id"
|
|
||||||
:active="selectedLeadId === lead.id"
|
|
||||||
class="nav-item"
|
|
||||||
clickable
|
|
||||||
@click="selectLead(lead.id)"
|
|
||||||
>
|
|
||||||
<v-list-item-icon>
|
|
||||||
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
|
|
||||||
</v-list-item-icon>
|
|
||||||
<v-list-item-content>
|
|
||||||
<v-text-overflow :text="getCompanyName(lead)" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #subtitle>
|
|
||||||
<template v-if="selectedLead">
|
|
||||||
<v-icon name="language" x-small />
|
|
||||||
<a :href="selectedLead.website_url" target="_blank" class="url-link">
|
|
||||||
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }}
|
|
||||||
</a>
|
|
||||||
· Status: {{ selectedLead.status.toUpperCase() }}
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<v-button
|
|
||||||
v-if="selectedLead?.status === 'new'"
|
|
||||||
secondary
|
|
||||||
:loading="loadingAudit"
|
|
||||||
@click="runAudit"
|
|
||||||
>
|
|
||||||
<v-icon name="settings_suggest" left />
|
|
||||||
Audit starten
|
|
||||||
</v-button>
|
|
||||||
|
|
||||||
<template v-if="selectedLead?.status === 'audit_ready'">
|
|
||||||
<v-button secondary :loading="loadingEmail" @click="sendAuditEmail">
|
|
||||||
<v-icon name="mail" left />
|
|
||||||
Audit E-Mail
|
|
||||||
</v-button>
|
|
||||||
<v-button :loading="loadingPdf" @click="generatePdf">
|
|
||||||
<v-icon name="picture_as_pdf" left />
|
|
||||||
PDF Erstellen
|
|
||||||
</v-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<v-button v-if="selectedLead?.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
|
|
||||||
<v-icon name="open_in_new" />
|
|
||||||
</v-button>
|
|
||||||
|
|
||||||
<v-button
|
|
||||||
v-if="selectedLead && !isCustomer(selectedLead.company)"
|
|
||||||
secondary
|
|
||||||
@click="linkAsCustomer"
|
|
||||||
>
|
|
||||||
<v-icon name="handshake" left />
|
|
||||||
Kunde verlinken
|
|
||||||
</v-button>
|
|
||||||
|
|
||||||
<v-button
|
|
||||||
v-if="selectedLead?.audit_pdf_path"
|
|
||||||
primary
|
|
||||||
:loading="loadingEmail"
|
|
||||||
@click="sendEstimateEmail"
|
|
||||||
>
|
|
||||||
<v-icon name="send" left />
|
|
||||||
Angebot senden
|
|
||||||
</v-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #empty-state>
|
|
||||||
Wähle einen Lead in der Navigation aus oder
|
|
||||||
<v-button x-small @click="openCreateDrawer">registriere einen neuen Lead</v-button>.
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="selectedLead" class="sections">
|
|
||||||
<div class="main-info">
|
|
||||||
<div class="form-grid">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Kontaktperson</span>
|
|
||||||
<div v-if="selectedLead.contact_person" class="value person-link" @click="goToPerson(selectedLead.contact_person)">
|
|
||||||
{{ getPersonName(selectedLead.contact_person) }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="value text-subdued">Keine Person verknüpft</div>
|
|
||||||
</div>
|
|
||||||
<div class="field full">
|
|
||||||
<span class="label">Briefing / Fokus</span>
|
|
||||||
<div class="value text-block">{{ selectedLead.briefing || 'Kein Briefing hinterlegt.' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<div v-if="selectedLead.ai_state" class="ai-observations">
|
|
||||||
<h3 class="section-title">AI Observations & Estimation</h3>
|
|
||||||
|
|
||||||
<div class="metrics">
|
|
||||||
<MintelStatCard label="Projekt-Modus" :value="selectedLead.ai_state.projectType || 'Unbekannt'" icon="category" />
|
|
||||||
<MintelStatCard label="Seitenanzahl" :value="selectedLead.ai_state.sitemap?.length || '0'" icon="description" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-table
|
|
||||||
v-if="selectedLead.ai_state.sitemap"
|
|
||||||
:headers="[ { text: 'Seite', value: 'title' }, { text: 'URL', value: 'url' } ]"
|
|
||||||
:items="selectedLead.ai_state.sitemap"
|
|
||||||
class="observation-table"
|
|
||||||
>
|
|
||||||
<template #[`item.title`]="{ item }">
|
|
||||||
<span class="page-title">{{ item.title }}</span>
|
|
||||||
</template>
|
|
||||||
<template #[`item.url`]="{ item }">
|
|
||||||
<span class="page-url">{{ item.url }}</span>
|
|
||||||
</template>
|
|
||||||
</v-table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Drawer: New Lead -->
|
|
||||||
<v-drawer
|
|
||||||
v-model="drawerActive"
|
|
||||||
title="Neuen Lead registrieren"
|
|
||||||
icon="person_add"
|
|
||||||
@cancel="drawerActive = false"
|
|
||||||
>
|
|
||||||
<div class="drawer-content">
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Organisation / Firma (Zentral)</span>
|
|
||||||
<MintelSelect
|
|
||||||
v-model="newLead.company"
|
|
||||||
:items="companyOptions"
|
|
||||||
placeholder="Bestehende Firma auswählen..."
|
|
||||||
allow-add
|
|
||||||
@add="openQuickAdd('company')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Website URL</span>
|
|
||||||
<v-input v-model="newLead.website_url" placeholder="https://..." />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Briefing / Fokus</span>
|
|
||||||
<v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Kontaktperson (Optional)</span>
|
|
||||||
<MintelSelect
|
|
||||||
v-model="newLead.contact_person"
|
|
||||||
:items="peopleOptions"
|
|
||||||
placeholder="Person auswählen..."
|
|
||||||
allow-add
|
|
||||||
@add="openQuickAdd('person')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="drawer-actions">
|
|
||||||
<v-button primary block :loading="savingLead" @click="saveLead">Lead speichern</v-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-drawer>
|
|
||||||
</MintelManagerLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed } from 'vue';
|
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
|
||||||
import { MintelManagerLayout, MintelSelect, MintelStatCard } from '@mintel/directus-extension-toolkit';
|
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const leads = ref<any[]>([]);
|
|
||||||
const selectedLeadId = ref<string | null>(null);
|
|
||||||
const loadingAudit = ref(false);
|
|
||||||
const loadingPdf = ref(false);
|
|
||||||
const loadingEmail = ref(false);
|
|
||||||
const drawerActive = ref(false);
|
|
||||||
const savingLead = ref(false);
|
|
||||||
const notice = ref<{ type: string; message: string } | null>(null);
|
|
||||||
|
|
||||||
const newLead = ref({
|
|
||||||
company: null,
|
|
||||||
website_url: '',
|
|
||||||
contact_person: null,
|
|
||||||
briefing: '',
|
|
||||||
status: 'new'
|
|
||||||
});
|
|
||||||
|
|
||||||
const companies = ref<any[]>([]);
|
|
||||||
const people = ref<any[]>([]);
|
|
||||||
const customers = ref<any[]>([]);
|
|
||||||
|
|
||||||
const companyOptions = computed(() =>
|
|
||||||
companies.value.map(c => ({
|
|
||||||
text: c.name,
|
|
||||||
value: c.id
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const peopleOptions = computed(() =>
|
|
||||||
people.value.map(p => ({
|
|
||||||
text: `${p.first_name} ${p.last_name}`,
|
|
||||||
value: p.id
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
function getCompanyName(lead: any) {
|
|
||||||
if (!lead) return '';
|
|
||||||
if (lead.company) {
|
|
||||||
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || 'Unbekannte Firma');
|
|
||||||
}
|
|
||||||
return 'Unbekannte Organisation';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPersonName(id: string | any) {
|
|
||||||
if (!id) return '';
|
|
||||||
if (typeof id === 'object') return `${id.first_name} ${id.last_name}`;
|
|
||||||
const person = people.value.find(p => p.id === id);
|
|
||||||
return person ? `${person.first_name} ${person.last_name}` : id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToPerson(id: string) {
|
|
||||||
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
|
|
||||||
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
const [leadsResp, peopleResp, companiesResp, customersResp] = await Promise.all([
|
|
||||||
api.get('/items/leads', {
|
|
||||||
params: {
|
|
||||||
sort: '-date_created',
|
|
||||||
fields: '*.*'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
api.get('/items/people', { params: { sort: 'last_name' } }),
|
|
||||||
api.get('/items/companies', { params: { sort: 'name' } }),
|
|
||||||
api.get('/items/customers', { params: { fields: ['company'] } })
|
|
||||||
]);
|
|
||||||
leads.value = leadsResp.data.data;
|
|
||||||
people.value = peopleResp.data.data;
|
|
||||||
companies.value = companiesResp.data.data;
|
|
||||||
customers.value = customersResp.data.data;
|
|
||||||
|
|
||||||
if (!selectedLeadId.value && leads.value.length > 0) {
|
|
||||||
selectedLeadId.value = leads.value[0].id;
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Fetch error:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCustomer(companyId: string | any) {
|
|
||||||
if (!companyId) return false;
|
|
||||||
const id = typeof companyId === 'object' ? companyId.id : companyId;
|
|
||||||
return customers.value.some(c => (typeof c.company === 'object' ? c.company.id : c.company) === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function linkAsCustomer() {
|
|
||||||
if (!selectedLead.value) return;
|
|
||||||
|
|
||||||
const companyId = selectedLead.value.company
|
|
||||||
? (typeof selectedLead.value.company === 'object' ? selectedLead.value.company.id : selectedLead.value.company)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const personId = selectedLead.value.contact_person
|
|
||||||
? (typeof selectedLead.value.contact_person === 'object' ? selectedLead.value.contact_person.id : selectedLead.value.contact_person)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
router.push({
|
|
||||||
name: 'module-customer-manager',
|
|
||||||
query: {
|
|
||||||
create: 'true',
|
|
||||||
company: companyId,
|
|
||||||
contact_person: personId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLeads() {
|
|
||||||
await fetchData();
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectLead(id: string) {
|
|
||||||
selectedLeadId.value = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDrawer() {
|
|
||||||
newLead.value = {
|
|
||||||
company: null,
|
|
||||||
website_url: '',
|
|
||||||
contact_person: null,
|
|
||||||
briefing: '',
|
|
||||||
status: 'new'
|
|
||||||
};
|
|
||||||
drawerActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAudit() {
|
|
||||||
if (!selectedLeadId.value) return;
|
|
||||||
loadingAudit.value = true;
|
|
||||||
try {
|
|
||||||
await api.post(`/acquisition/audit/${selectedLeadId.value}`);
|
|
||||||
notice.value = { type: 'success', message: 'Audit erfolgreich gestartet!' };
|
|
||||||
await fetchLeads();
|
|
||||||
} catch (e: any) {
|
|
||||||
notice.value = { type: 'danger', message: `Fehler beim Audit: ${e.message}` };
|
|
||||||
} finally {
|
|
||||||
loadingAudit.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendAuditEmail() {
|
|
||||||
if (!selectedLeadId.value) return;
|
|
||||||
loadingEmail.value = true;
|
|
||||||
try {
|
|
||||||
await api.post(`/acquisition/audit-email/${selectedLeadId.value}`);
|
|
||||||
notice.value = { type: 'success', message: 'Audit E-Mail versendet!' };
|
|
||||||
await fetchLeads();
|
|
||||||
} catch (e: any) {
|
|
||||||
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
|
|
||||||
} finally {
|
|
||||||
loadingEmail.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generatePdf() {
|
|
||||||
if (!selectedLeadId.value) return;
|
|
||||||
loadingPdf.value = true;
|
|
||||||
try {
|
|
||||||
await api.post(`/acquisition/estimate/${selectedLeadId.value}`);
|
|
||||||
notice.value = { type: 'success', message: 'Angebot (PDF) wurde generiert!' };
|
|
||||||
await fetchLeads();
|
|
||||||
} catch (e: any) {
|
|
||||||
notice.value = { type: 'danger', message: `Fehler bei PDF Generierung: ${e.message}` };
|
|
||||||
} finally {
|
|
||||||
loadingPdf.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendEstimateEmail() {
|
|
||||||
if (!selectedLeadId.value) return;
|
|
||||||
loadingEmail.value = true;
|
|
||||||
try {
|
|
||||||
await api.post(`/acquisition/estimate-email/${selectedLeadId.value}`);
|
|
||||||
notice.value = { type: 'success', message: 'Angebot erfolgreich versendet!' };
|
|
||||||
await fetchLeads();
|
|
||||||
} catch (e: any) {
|
|
||||||
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
|
|
||||||
} finally {
|
|
||||||
loadingEmail.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPdf() {
|
|
||||||
if (!selectedLead.value?.audit_pdf_path) return;
|
|
||||||
window.open(`${window.location.origin}/assets/${selectedLead.value.audit_pdf_path}`, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveLead() {
|
|
||||||
if (!newLead.value.company) {
|
|
||||||
notice.value = { type: 'danger', message: 'Organisation erforderlich.' };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
savingLead.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
...newLead.value
|
|
||||||
};
|
|
||||||
await api.post('/items/leads', payload);
|
|
||||||
notice.value = { type: 'success', message: 'Lead erfolgreich registriert!' };
|
|
||||||
drawerActive.value = false;
|
|
||||||
await fetchLeads();
|
|
||||||
selectedLeadId.value = payload.id;
|
|
||||||
} catch (e: any) {
|
|
||||||
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
|
|
||||||
} finally {
|
|
||||||
savingLead.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openQuickAdd(type: string) {
|
|
||||||
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusIcon(status: string) {
|
|
||||||
switch(status) {
|
|
||||||
case 'new': return 'fiber_new';
|
|
||||||
case 'auditing': return 'hourglass_empty';
|
|
||||||
case 'audit_ready': return 'check_circle';
|
|
||||||
case 'contacted': return 'mail_outline';
|
|
||||||
default: return 'help_outline';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusColor(status: string) {
|
|
||||||
switch(status) {
|
|
||||||
case 'new': return 'var(--theme--primary)';
|
|
||||||
case 'auditing': return 'var(--theme--warning)';
|
|
||||||
case 'audit_ready': return 'var(--theme--success)';
|
|
||||||
case 'contacted': return 'var(--theme--secondary)';
|
|
||||||
default: return 'var(--theme--foreground-subdued)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchData();
|
|
||||||
if (route.query.create === 'true') {
|
|
||||||
openCreateDrawer();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
|
|
||||||
.url-link:hover { border-bottom-color: currentColor; }
|
|
||||||
|
|
||||||
.sections { display: flex; flex-direction: column; gap: 32px; }
|
|
||||||
|
|
||||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
|
||||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.field.full { grid-column: span 2; }
|
|
||||||
.label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
|
||||||
.value { font-size: 15px; color: var(--theme--foreground); }
|
|
||||||
.text-block { line-height: 1.6; white-space: pre-wrap; background: var(--theme--background-subdued); padding: 16px; border-radius: 8px; }
|
|
||||||
|
|
||||||
.ai-observations { display: flex; flex-direction: column; gap: 16px; }
|
|
||||||
.section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; }
|
|
||||||
.metrics { display: flex; gap: 24px; margin-bottom: 16px; }
|
|
||||||
|
|
||||||
.observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; }
|
|
||||||
.page-title { font-weight: 600; }
|
|
||||||
.page-url { font-family: var(--family-monospace); 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; }
|
|
||||||
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
</style>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { build } from 'esbuild';
|
|
||||||
import { resolve, dirname } from 'path';
|
|
||||||
import { mkdirSync } from 'fs';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const entryPoint = resolve(__dirname, 'src/index.ts');
|
|
||||||
const outfile = resolve(__dirname, 'dist/index.js');
|
|
||||||
|
|
||||||
try {
|
|
||||||
mkdirSync(dirname(outfile), { recursive: true });
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Building from ${entryPoint} to ${outfile}...`);
|
|
||||||
|
|
||||||
build({
|
|
||||||
entryPoints: [entryPoint],
|
|
||||||
bundle: true,
|
|
||||||
platform: 'node',
|
|
||||||
target: 'node18',
|
|
||||||
outfile: outfile,
|
|
||||||
jsx: 'automatic',
|
|
||||||
format: 'esm',
|
|
||||||
// footer: {
|
|
||||||
// js: "module.exports = module.exports.default || module.exports;",
|
|
||||||
// },
|
|
||||||
loader: {
|
|
||||||
'.tsx': 'tsx',
|
|
||||||
'.ts': 'ts',
|
|
||||||
'.js': 'js',
|
|
||||||
},
|
|
||||||
external: ["canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
|
|
||||||
plugins: [{
|
|
||||||
name: 'mock-canvas',
|
|
||||||
setup(build) {
|
|
||||||
build.onResolve({ filter: /^canvas/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
|
|
||||||
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
name: 'mock-jsdom',
|
|
||||||
setup(build) {
|
|
||||||
build.onResolve({ filter: /^jsdom/ }, args => ({ path: args.path, namespace: 'mock-jsdom' }));
|
|
||||||
build.onLoad({ filter: /.*/, namespace: 'mock-jsdom' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}).then(() => {
|
|
||||||
console.log("Build succeeded!");
|
|
||||||
}).catch((e) => {
|
|
||||||
console.error("Build failed:", e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "acquisition",
|
|
||||||
"version": "1.8.21",
|
|
||||||
"type": "module",
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "endpoint",
|
|
||||||
"path": "dist/index.js",
|
|
||||||
"source": "src/index.ts",
|
|
||||||
"host": "^11.0.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "node build.mjs",
|
|
||||||
"dev": "node build.mjs --watch"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"@mintel/pdf": "workspace:*",
|
|
||||||
"@mintel/mail": "workspace:*",
|
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"typescript": "^5.6.3"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"jquery": "^3.7.1",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-dom": "^19.2.4"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
import { defineEndpoint } from "@directus/extensions-sdk";
|
|
||||||
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
|
|
||||||
import {
|
|
||||||
render,
|
|
||||||
SiteAuditTemplate,
|
|
||||||
ProjectEstimateTemplate,
|
|
||||||
} from "@mintel/mail";
|
|
||||||
import { createElement } from "react";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
export default defineEndpoint((router, { services, env }) => {
|
|
||||||
const { ItemsService, MailService } = services;
|
|
||||||
|
|
||||||
router.get("/ping", (req, res) => res.send("pong"));
|
|
||||||
|
|
||||||
router.post("/audit/:id", async (req: any, res: any) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const leadsService = new ItemsService("leads", {
|
|
||||||
schema: req.schema,
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lead = await leadsService.readOne(id);
|
|
||||||
if (!lead) return res.status(404).send({ error: "Lead not found" });
|
|
||||||
|
|
||||||
await leadsService.updateOne(id, { status: "auditing" });
|
|
||||||
|
|
||||||
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
|
|
||||||
const result = await acqService.runFullSequence(
|
|
||||||
lead.website_url,
|
|
||||||
lead.briefing,
|
|
||||||
lead.comments,
|
|
||||||
);
|
|
||||||
|
|
||||||
await leadsService.updateOne(id, {
|
|
||||||
status: "audit_ready",
|
|
||||||
ai_state: result.state,
|
|
||||||
audit_context: JSON.stringify(result.usage),
|
|
||||||
});
|
|
||||||
|
|
||||||
res.send({ success: true, result });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Audit failed:", error);
|
|
||||||
await leadsService.updateOne(id, {
|
|
||||||
status: "new",
|
|
||||||
comments: `Audit failed: ${error.message}`,
|
|
||||||
});
|
|
||||||
res.status(500).send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/audit-email/:id", async (req: any, res: any) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { ItemsService, MailService } = services;
|
|
||||||
const leadsService = new ItemsService("leads", {
|
|
||||||
schema: req.schema,
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
const _peopleService = new ItemsService("people", {
|
|
||||||
schema: req.schema,
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
const companiesService = new ItemsService("companies", {
|
|
||||||
schema: req.schema,
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
const mailService = new MailService({
|
|
||||||
schema: req.schema,
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lead = await leadsService.readOne(id, {
|
|
||||||
fields: ["*", "company.*", "contact_person.*"],
|
|
||||||
});
|
|
||||||
if (!lead || !lead.ai_state)
|
|
||||||
return res.status(400).send({ error: "Lead or Audit not ready" });
|
|
||||||
|
|
||||||
let recipientEmail = lead.contact_email;
|
|
||||||
let companyName = lead.company?.name || lead.company_name;
|
|
||||||
|
|
||||||
if (lead.contact_person) {
|
|
||||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
|
||||||
|
|
||||||
if (lead.contact_person.company) {
|
|
||||||
const personCompany = await companiesService.readOne(
|
|
||||||
lead.contact_person.company,
|
|
||||||
);
|
|
||||||
companyName = personCompany?.name || companyName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!recipientEmail)
|
|
||||||
return res.status(400).send({ error: "No recipient email found" });
|
|
||||||
|
|
||||||
const auditHighlights = [
|
|
||||||
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
|
|
||||||
...(lead.ai_state.sitemap || [])
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((item: any) => `Potenzial in: ${item.category}`),
|
|
||||||
];
|
|
||||||
|
|
||||||
const html = await render(
|
|
||||||
createElement(SiteAuditTemplate, {
|
|
||||||
companyName: companyName,
|
|
||||||
websiteUrl: lead.website_url,
|
|
||||||
auditHighlights,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await mailService.send({
|
|
||||||
to: recipientEmail,
|
|
||||||
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
await leadsService.updateOne(id, {
|
|
||||||
status: "contacted",
|
|
||||||
last_contacted_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
res.send({ success: true });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Audit Email failed:", error);
|
|
||||||
res.status(500).send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/estimate/:id", async (req: any, res: any) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const leadsService = new ItemsService("leads", {
|
|
||||||
schema: req.schema,
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lead = await leadsService.readOne(id);
|
|
||||||
if (!lead || !lead.ai_state)
|
|
||||||
return res.status(400).send({ error: "Lead or AI state not found" });
|
|
||||||
|
|
||||||
const pdfEngine = new PdfEngine();
|
|
||||||
const filename = `estimate_${id}_${Date.now()}.pdf`;
|
|
||||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
|
||||||
const outputPath = path.join(storageRoot, filename);
|
|
||||||
|
|
||||||
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
|
|
||||||
|
|
||||||
await leadsService.updateOne(id, {
|
|
||||||
audit_pdf_path: filename,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.send({ success: true, filename });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("PDF Generation failed:", error);
|
|
||||||
res.status(500).send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/estimate-email/:id", async (req: any, res: any) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const leadsService = new ItemsService("leads", {
|
|
||||||
schema: req.schema,
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
const _peopleService = new ItemsService("people", {
|
|
||||||
schema: req.schema,
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
const companiesService = new ItemsService("companies", {
|
|
||||||
schema: req.schema,
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
const mailService = new MailService({
|
|
||||||
schema: req.schema,
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lead = await leadsService.readOne(id, {
|
|
||||||
fields: ["*", "company.*", "contact_person.*"],
|
|
||||||
});
|
|
||||||
if (!lead || !lead.audit_pdf_path)
|
|
||||||
return res.status(400).send({ error: "PDF not generated" });
|
|
||||||
|
|
||||||
let recipientEmail = lead.contact_email;
|
|
||||||
let companyName = lead.company?.name || lead.company_name;
|
|
||||||
|
|
||||||
if (lead.contact_person) {
|
|
||||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
|
||||||
|
|
||||||
if (lead.contact_person.company) {
|
|
||||||
const personCompany = await companiesService.readOne(
|
|
||||||
lead.contact_person.company,
|
|
||||||
);
|
|
||||||
companyName = personCompany?.name || companyName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!recipientEmail)
|
|
||||||
return res.status(400).send({ error: "No recipient email found" });
|
|
||||||
|
|
||||||
const html = await render(
|
|
||||||
createElement(ProjectEstimateTemplate, {
|
|
||||||
companyName: companyName,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
|
||||||
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
|
|
||||||
|
|
||||||
await mailService.send({
|
|
||||||
to: recipientEmail,
|
|
||||||
subject: `Ihre Projekt-Schätzung: ${companyName}`,
|
|
||||||
html,
|
|
||||||
attachments: [
|
|
||||||
{
|
|
||||||
filename: `Angebot_${companyName}.pdf`,
|
|
||||||
content: fs.readFileSync(attachmentPath),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await leadsService.updateOne(id, {
|
|
||||||
status: "contacted",
|
|
||||||
last_contacted_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
res.send({ success: true });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Estimate Email failed:", error);
|
|
||||||
res.status(500).send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -36,153 +36,15 @@ program
|
|||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(`
|
chalk.yellow(`
|
||||||
📱 App: http://localhost:3000
|
📱 App: http://localhost:3000
|
||||||
🗄️ CMS: http://localhost:8055/admin
|
|
||||||
🚦 Traefik: http://localhost:8080
|
🚦 Traefik: http://localhost:8080
|
||||||
`),
|
`),
|
||||||
);
|
);
|
||||||
execSync(
|
execSync(
|
||||||
"docker compose down --remove-orphans && docker compose up -d app directus at-mintel-directus-db",
|
"docker compose down --remove-orphans && docker compose up -d app",
|
||||||
{ stdio: "inherit" },
|
{ stdio: "inherit" },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const directus = program
|
|
||||||
.command("directus")
|
|
||||||
.description("Directus management commands");
|
|
||||||
|
|
||||||
directus
|
|
||||||
.command("bootstrap")
|
|
||||||
.description("Setup Directus branding and settings")
|
|
||||||
.action(async () => {
|
|
||||||
const { execSync } = await import("child_process");
|
|
||||||
console.log(chalk.blue("🎨 Bootstrapping Directus..."));
|
|
||||||
execSync("npx tsx --env-file=.env scripts/setup-directus.ts", {
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
directus
|
|
||||||
.command("bootstrap-feedback")
|
|
||||||
.description("Setup Directus collections and flows for Feedback")
|
|
||||||
.action(async () => {
|
|
||||||
const { execSync } = await import("child_process");
|
|
||||||
console.log(chalk.blue("📧 Bootstrapping Visual Feedback System..."));
|
|
||||||
// Use the logic from setup-feedback-hardened.ts
|
|
||||||
const bootstrapScript = `
|
|
||||||
import { createDirectus, rest, authentication, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies, readRoles, readUsers } from '@directus/sdk';
|
|
||||||
|
|
||||||
async function setup() {
|
|
||||||
const url = process.env.DIRECTUS_URL || 'http://localhost:8055';
|
|
||||||
const email = process.env.DIRECTUS_ADMIN_EMAIL;
|
|
||||||
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
console.error('❌ DIRECTUS_ADMIN_EMAIL or DIRECTUS_ADMIN_PASSWORD not set');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = createDirectus(url).with(authentication('json')).with(rest());
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('🔑 Authenticating...');
|
|
||||||
await client.login(email, password);
|
|
||||||
|
|
||||||
const roles = await client.request(readRoles());
|
|
||||||
const adminRole = roles.find(r => r.name === 'Administrator');
|
|
||||||
const policies = await client.request(readPolicies());
|
|
||||||
const adminPolicy = policies.find(p => p.name === 'Administrator');
|
|
||||||
|
|
||||||
console.log('🏗️ Creating Collection "visual_feedback"...');
|
|
||||||
try {
|
|
||||||
await client.request(createCollection({
|
|
||||||
collection: 'visual_feedback',
|
|
||||||
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
|
|
||||||
fields: [
|
|
||||||
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
|
|
||||||
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
|
|
||||||
{ field: 'url', type: 'string' },
|
|
||||||
{ field: 'selector', type: 'string' },
|
|
||||||
{ field: 'x', type: 'float' },
|
|
||||||
{ field: 'y', type: 'float' },
|
|
||||||
{ field: 'type', type: 'string' },
|
|
||||||
{ field: 'text', type: 'text' },
|
|
||||||
{ field: 'user_name', type: 'string' },
|
|
||||||
{ field: 'user_identity', type: 'string' },
|
|
||||||
{ field: 'screenshot', type: 'uuid', meta: { interface: 'file' } },
|
|
||||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
|
||||||
]
|
|
||||||
} as any));
|
|
||||||
} catch (_e) { console.log(' (Collection might already exist)'); }
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.request(createCollection({
|
|
||||||
collection: 'visual_feedback_comments',
|
|
||||||
meta: { icon: 'comment' },
|
|
||||||
fields: [
|
|
||||||
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
|
|
||||||
{ field: 'feedback_id', type: 'uuid', meta: { interface: 'select-dropdown' } },
|
|
||||||
{ field: 'user_name', type: 'string' },
|
|
||||||
{ field: 'text', type: 'text' },
|
|
||||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
|
||||||
]
|
|
||||||
} as any));
|
|
||||||
} catch (e) { }
|
|
||||||
|
|
||||||
if (adminPolicy) {
|
|
||||||
console.log('🔐 Granting ALL permissions to Administrator Policy...');
|
|
||||||
for (const coll of ['visual_feedback', 'visual_feedback_comments']) {
|
|
||||||
for (const action of ['create', 'read', 'update', 'delete']) {
|
|
||||||
try {
|
|
||||||
await client.request(createPermission({
|
|
||||||
collection: coll,
|
|
||||||
action,
|
|
||||||
fields: ['*'],
|
|
||||||
policy: adminPolicy.id
|
|
||||||
} as any));
|
|
||||||
} catch (_e) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('📊 Creating Dashboard...');
|
|
||||||
try {
|
|
||||||
const dash = await client.request(createDashboard({ name: 'Visual Feedback', icon: 'feedback', color: '#6366f1' }));
|
|
||||||
await client.request(createPanel({
|
|
||||||
dashboard: dash.id,
|
|
||||||
name: 'Total Feedbacks',
|
|
||||||
type: 'metric',
|
|
||||||
width: 12, height: 6, position_x: 1, position_y: 1,
|
|
||||||
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
|
|
||||||
} as any));
|
|
||||||
} catch (e) { }
|
|
||||||
|
|
||||||
console.log('✨ FEEDBACK BOOTSTRAP DONE.');
|
|
||||||
} catch (e) { console.error('❌ FAILURE:', e); }
|
|
||||||
}
|
|
||||||
setup();
|
|
||||||
`;
|
|
||||||
const tempFile = path.join(process.cwd(), "temp-bootstrap-feedback.ts");
|
|
||||||
await fs.writeFile(tempFile, bootstrapScript);
|
|
||||||
try {
|
|
||||||
execSync("npx tsx --env-file=.env " + tempFile, { stdio: "inherit" });
|
|
||||||
} finally {
|
|
||||||
await fs.remove(tempFile);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
directus
|
|
||||||
.command("sync <action> <env>")
|
|
||||||
.description("Sync Directus data (push/pull) for a specific environment")
|
|
||||||
.action(async (action, env) => {
|
|
||||||
const { execSync } = await import("child_process");
|
|
||||||
console.log(
|
|
||||||
chalk.blue(`📥 Executing Directus sync: ${action} -> ${env}...`),
|
|
||||||
);
|
|
||||||
execSync(`./scripts/sync-directus.sh ${action} ${env}`, {
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("pagespeed")
|
.command("pagespeed")
|
||||||
.description("Run PageSpeed (Lighthouse) tests")
|
.description("Run PageSpeed (Lighthouse) tests")
|
||||||
@@ -221,13 +83,6 @@ program
|
|||||||
lint: "next lint",
|
lint: "next lint",
|
||||||
typecheck: "tsc --noEmit",
|
typecheck: "tsc --noEmit",
|
||||||
test: "vitest run --passWithNoTests",
|
test: "vitest run --passWithNoTests",
|
||||||
"directus:bootstrap": "mintel directus bootstrap",
|
|
||||||
"directus:push:testing": "mintel directus sync push testing",
|
|
||||||
"directus:pull:testing": "mintel directus sync pull testing",
|
|
||||||
"directus:push:staging": "mintel directus sync push staging",
|
|
||||||
"directus:pull:staging": "mintel directus sync pull staging",
|
|
||||||
"directus:push:prod": "mintel directus sync push production",
|
|
||||||
"directus:pull:prod": "mintel directus sync pull production",
|
|
||||||
"pagespeed:test": "mintel pagespeed",
|
"pagespeed:test": "mintel pagespeed",
|
||||||
},
|
},
|
||||||
dependencies: {
|
dependencies: {
|
||||||
@@ -236,7 +91,6 @@ program
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
"@mintel/next-observability": "workspace:*",
|
"@mintel/next-observability": "workspace:*",
|
||||||
"@directus/sdk": "^21.0.0",
|
|
||||||
},
|
},
|
||||||
devDependencies: {
|
devDependencies: {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
@@ -473,15 +327,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Directus structure
|
|
||||||
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
|
|
||||||
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
|
|
||||||
await fs.writeFile(path.join(fullPath, "directus/uploads/.gitkeep"), "");
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(fullPath, "directus/extensions/.gitkeep"),
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create .env.example
|
// Create .env.example
|
||||||
const envExample = `# Project
|
const envExample = `# Project
|
||||||
PROJECT_NAME=${projectName}
|
PROJECT_NAME=${projectName}
|
||||||
@@ -493,21 +338,10 @@ AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
|||||||
|
|
||||||
# Host Config (Local)
|
# Host Config (Local)
|
||||||
TRAEFIK_HOST=\`${projectName}.localhost\`
|
TRAEFIK_HOST=\`${projectName}.localhost\`
|
||||||
DIRECTUS_HOST=\`cms.${projectName}.localhost\`
|
|
||||||
|
|
||||||
# Next.js
|
# Next.js
|
||||||
NEXT_PUBLIC_BASE_URL=http://${projectName}.localhost
|
NEXT_PUBLIC_BASE_URL=http://${projectName}.localhost
|
||||||
|
|
||||||
# Directus
|
|
||||||
DIRECTUS_URL=http://cms.${projectName}.localhost
|
|
||||||
DIRECTUS_KEY=$(openssl rand -hex 32 2>/dev/null || echo "mintel-key")
|
|
||||||
DIRECTUS_SECRET=$(openssl rand -hex 32 2>/dev/null || echo "mintel-secret")
|
|
||||||
DIRECTUS_ADMIN_EMAIL=admin@mintel.me
|
|
||||||
DIRECTUS_ADMIN_PASSWORD=mintel-admin-pass
|
|
||||||
DIRECTUS_DB_NAME=directus
|
|
||||||
DIRECTUS_DB_USER=directus
|
|
||||||
DIRECTUS_DB_PASSWORD=mintel-db-pass
|
|
||||||
|
|
||||||
# Sentry / Glitchtip
|
# Sentry / Glitchtip
|
||||||
SENTRY_DSN=
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
FROM directus/directus:11
|
|
||||||
|
|
||||||
USER root
|
|
||||||
# Install dependencies in a way that avoids metadata conflicts in the root
|
|
||||||
RUN mkdir -p /directus/lib-dependencies && \
|
|
||||||
cd /directus/lib-dependencies && \
|
|
||||||
npm init -y && \
|
|
||||||
npm install vue @vueuse/core vue-router
|
|
||||||
# Ensure they are in the NODE_PATH
|
|
||||||
ENV NODE_PATH="/directus/lib-dependencies/node_modules:${NODE_PATH}"
|
|
||||||
USER node
|
|
||||||
Binary file not shown.
@@ -1,53 +0,0 @@
|
|||||||
services:
|
|
||||||
infra-cms:
|
|
||||||
image: directus/directus:11.15.2
|
|
||||||
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"
|
|
||||||
PUBLIC_URL: "http://cms.localhost"
|
|
||||||
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"
|
|
||||||
LOG_LEVEL: "debug"
|
|
||||||
SERVE_APP: "true"
|
|
||||||
EXTENSIONS_AUTO_RELOAD: "true"
|
|
||||||
EXTENSIONS_SANDBOX: "false"
|
|
||||||
CONTENT_SECURITY_POLICY: "false"
|
|
||||||
volumes:
|
|
||||||
- ./database:/directus/database
|
|
||||||
- ./uploads:/directus/uploads
|
|
||||||
- ./schema:/directus/schema
|
|
||||||
- ./extensions:/directus/extensions
|
|
||||||
healthcheck:
|
|
||||||
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
labels:
|
|
||||||
traefik.enable: "true"
|
|
||||||
traefik.http.routers.at-mintel-infra-cms.rule: "Host(`cms.localhost`)"
|
|
||||||
traefik.docker.network: "infra"
|
|
||||||
caddy: "http://cms.localhost"
|
|
||||||
caddy.reverse_proxy: "{{upstreams 8055}}"
|
|
||||||
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate, max-age=0"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
name: at-mintel-cms-network
|
|
||||||
infra:
|
|
||||||
external: true
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@mintel/cms-infra",
|
|
||||||
"version": "1.8.21",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "npm run up -- --link",
|
|
||||||
"up": "../../scripts/cms-up.sh",
|
|
||||||
"down": "docker compose down",
|
|
||||||
"logs": "docker compose logs -f",
|
|
||||||
"build:extensions": "../../scripts/sync-extensions.sh",
|
|
||||||
"schema:apply:local": "../../scripts/cms-apply.sh local",
|
|
||||||
"schema:apply:infra": "../../scripts/cms-apply.sh infra",
|
|
||||||
"snapshot:local": "../../scripts/cms-snapshot.sh",
|
|
||||||
"sync:push": "../../scripts/sync-directus.sh push infra",
|
|
||||||
"sync:pull": "../../scripts/sync-directus.sh pull infra"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "company-manager",
|
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
|
||||||
"icon": "extension",
|
|
||||||
"version": "1.8.21",
|
|
||||||
"type": "module",
|
|
||||||
"keywords": [
|
|
||||||
"directus",
|
|
||||||
"directus-extension",
|
|
||||||
"directus-extension-module"
|
|
||||||
],
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "module",
|
|
||||||
"path": "dist/index.js",
|
|
||||||
"source": "src/index.ts",
|
|
||||||
"host": "app",
|
|
||||||
"name": "company manager"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "directus-extension build",
|
|
||||||
"dev": "directus-extension build -w"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mintel/directus-extension-toolkit": "workspace:*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { defineModule } from '@directus/extensions-sdk';
|
|
||||||
import ModuleComponent from './module.vue';
|
|
||||||
|
|
||||||
export default defineModule({
|
|
||||||
id: 'company-manager',
|
|
||||||
name: 'Company Manager',
|
|
||||||
icon: 'business',
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
component: ModuleComponent,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
<template>
|
|
||||||
<MintelManagerLayout
|
|
||||||
title="Company Manager"
|
|
||||||
:item-title="selectedCompany?.name || 'Firma wählen'"
|
|
||||||
:is-empty="!selectedCompany"
|
|
||||||
empty-title="Firma auswählen"
|
|
||||||
empty-icon="business"
|
|
||||||
:notice="feedback"
|
|
||||||
@close-notice="feedback = null"
|
|
||||||
>
|
|
||||||
<template #navigation>
|
|
||||||
<v-list nav>
|
|
||||||
<v-list-item @click="openCreateDrawer" 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="nav-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 #subtitle>
|
|
||||||
<template v-if="selectedCompany">
|
|
||||||
{{ selectedCompany.domain || 'Keine Domain angegeben' }}
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditDrawer">
|
|
||||||
<v-icon name="edit" />
|
|
||||||
</v-button>
|
|
||||||
<v-button danger rounded icon v-tooltip.bottom="'Firma löschen'" @click="deleteCompany">
|
|
||||||
<v-icon name="delete" />
|
|
||||||
</v-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #empty-state>
|
|
||||||
Wähle eine Firma in der Navigation aus oder
|
|
||||||
<v-button x-small @click="openCreateDrawer">erstelle eine neue Firma</v-button>.
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="selectedCompany" class="details-grid">
|
|
||||||
<div class="detail-item full">
|
|
||||||
<span class="label">Notizen / Adresse</span>
|
|
||||||
<p class="value">{{ selectedCompany.notes || '---' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create/Edit Drawer -->
|
|
||||||
<v-drawer
|
|
||||||
v-model="drawerActive"
|
|
||||||
:title="isEditing ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
|
|
||||||
icon="business"
|
|
||||||
@cancel="drawerActive = false"
|
|
||||||
>
|
|
||||||
<template #default>
|
|
||||||
<div class="drawer-content">
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Firmenname</span>
|
|
||||||
<v-input v-model="form.name" placeholder="z.B. Schmidt GmbH" autofocus />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Domain / Website</span>
|
|
||||||
<v-input v-model="form.domain" placeholder="example.com" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Notizen / Adresse</span>
|
|
||||||
<v-textarea v-model="form.notes" placeholder="z.B. Branche, Adresse, etc." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="drawer-actions">
|
|
||||||
<v-button primary block :loading="saving" @click="saveCompany">
|
|
||||||
Firma speichern
|
|
||||||
</v-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-drawer>
|
|
||||||
</MintelManagerLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { MintelManagerLayout } from '@mintel/directus-extension-toolkit';
|
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
const route = useRoute();
|
|
||||||
const companies = ref([]);
|
|
||||||
const selectedCompany = ref(null);
|
|
||||||
const feedback = ref(null);
|
|
||||||
const saving = ref(false);
|
|
||||||
const drawerActive = ref(false);
|
|
||||||
const isEditing = ref(false);
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
domain: '',
|
|
||||||
notes: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
const resp = await api.get('/items/companies', {
|
|
||||||
params: { sort: 'name' }
|
|
||||||
});
|
|
||||||
companies.value = resp.data.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch companies:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectCompany(company: any) {
|
|
||||||
selectedCompany.value = company;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDrawer() {
|
|
||||||
isEditing.value = false;
|
|
||||||
form.value = {
|
|
||||||
id: null,
|
|
||||||
name: '',
|
|
||||||
domain: '',
|
|
||||||
notes: ''
|
|
||||||
};
|
|
||||||
drawerActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditDrawer() {
|
|
||||||
isEditing.value = true;
|
|
||||||
form.value = {
|
|
||||||
id: selectedCompany.value.id,
|
|
||||||
name: selectedCompany.value.name,
|
|
||||||
domain: selectedCompany.value.domain,
|
|
||||||
notes: selectedCompany.value.notes
|
|
||||||
};
|
|
||||||
drawerActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCompany() {
|
|
||||||
if (!form.value.name) {
|
|
||||||
feedback.value = { type: 'danger', message: 'Firmenname ist erforderlich.' };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
let updatedItem;
|
|
||||||
if (isEditing.value) {
|
|
||||||
const res = await api.patch(`/items/companies/${form.value.id}`, form.value);
|
|
||||||
updatedItem = res.data.data;
|
|
||||||
feedback.value = { type: 'success', message: 'Firma aktualisiert!' };
|
|
||||||
} else {
|
|
||||||
const res = await api.post('/items/companies', form.value);
|
|
||||||
updatedItem = res.data.data;
|
|
||||||
feedback.value = { type: 'success', message: 'Firma angelegt!' };
|
|
||||||
}
|
|
||||||
drawerActive.value = false;
|
|
||||||
await fetchData();
|
|
||||||
if (updatedItem) {
|
|
||||||
selectedCompany.value = companies.value.find(c => c.id === updatedItem.id) || updatedItem;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
feedback.value = { type: 'danger', message: error.message };
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteCompany() {
|
|
||||||
if (!confirm('Soll diese Firma wirklich gelöscht werden?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.delete(`/items/companies/${selectedCompany.value.id}`);
|
|
||||||
feedback.value = { type: 'success', message: 'Firma gelöscht.' };
|
|
||||||
selectedCompany.value = null;
|
|
||||||
await fetchData();
|
|
||||||
} catch (error) {
|
|
||||||
feedback.value = { type: 'danger', message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchData();
|
|
||||||
if (route.query.create === 'true') {
|
|
||||||
openCreateDrawer();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.details-grid { display: flex; flex-direction: column; gap: 24px; }
|
|
||||||
.detail-item { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.detail-item.full { width: 100%; }
|
|
||||||
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
|
||||||
.value { font-size: 16px; font-weight: 500; }
|
|
||||||
.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; }
|
|
||||||
.drawer-actions { margin-top: 24px; }
|
|
||||||
</style>
|
|
||||||
@@ -1,40 +1,39 @@
|
|||||||
import { config as dotenvConfig } from 'dotenv';
|
import { config as dotenvConfig } from "dotenv";
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from "node:fs/promises";
|
||||||
import { EstimationPipeline } from './pipeline.js';
|
import { ConceptPipeline } from "./pipeline.js";
|
||||||
|
|
||||||
dotenvConfig({ path: path.resolve(process.cwd(), '../../.env') });
|
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
|
||||||
|
|
||||||
const briefing = await fs.readFile(
|
const briefing = await fs.readFile(
|
||||||
path.resolve(process.cwd(), '../../data/briefings/etib.txt'),
|
path.resolve(process.cwd(), "../../data/briefings/etib.txt"),
|
||||||
'utf8',
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Briefing loaded: ${briefing.length} chars`);
|
console.log(`Briefing loaded: ${briefing.length} chars`);
|
||||||
|
|
||||||
const pipeline = new EstimationPipeline(
|
const pipeline = new ConceptPipeline(
|
||||||
{
|
{
|
||||||
openrouterKey: process.env.OPENROUTER_API_KEY || '',
|
openrouterKey: process.env.OPENROUTER_API_KEY || "",
|
||||||
zyteApiKey: process.env.ZYTE_API_KEY,
|
zyteApiKey: process.env.ZYTE_API_KEY,
|
||||||
outputDir: path.resolve(process.cwd(), '../../out/estimations'),
|
outputDir: path.resolve(process.cwd(), "../../out/estimations"),
|
||||||
crawlDir: path.resolve(process.cwd(), '../../data/crawls'),
|
crawlDir: path.resolve(process.cwd(), "../../data/crawls"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onStepStart: (id, name) => console.log(`[CB] Starting: ${id}`),
|
onStepStart: (id, _name) => console.log(`[CB] Starting: ${id}`),
|
||||||
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
|
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
|
||||||
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
|
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pipeline.run({
|
await pipeline.run({
|
||||||
briefing,
|
briefing,
|
||||||
url: 'https://www.e-tib.com',
|
url: "https://www.e-tib.com",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('\n✨ Pipeline complete!');
|
console.log("\n✨ Pipeline complete!");
|
||||||
console.log('Validation:', result.validationResult?.passed ? 'PASSED' : 'FAILED');
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('\n❌ Pipeline failed:', err.message);
|
console.error("\n❌ Pipeline failed:", err.message);
|
||||||
console.error(err.stack);
|
console.error(err.stack);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ program
|
|||||||
.option("--output <dir>", "Output directory", "../../out/concepts")
|
.option("--output <dir>", "Output directory", "../../out/concepts")
|
||||||
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
|
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
|
||||||
.action(async (briefingArg: string | undefined, options: any) => {
|
.action(async (briefingArg: string | undefined, options: any) => {
|
||||||
const openrouterKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
const openrouterKey =
|
||||||
|
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||||
if (!openrouterKey) {
|
if (!openrouterKey) {
|
||||||
console.error("❌ OPENROUTER_API_KEY not found in environment.");
|
console.error("❌ OPENROUTER_API_KEY not found in environment.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -77,10 +78,10 @@ program
|
|||||||
crawlDir: path.resolve(process.cwd(), options.crawlDir),
|
crawlDir: path.resolve(process.cwd(), options.crawlDir),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onStepStart: (id, name) => {
|
onStepStart: (_id, _name) => {
|
||||||
// Will be enhanced with Ink spinner later
|
// Will be enhanced with Ink spinner later
|
||||||
},
|
},
|
||||||
onStepComplete: (id, result) => {
|
onStepComplete: (_id, _result) => {
|
||||||
// Will be enhanced with Ink UI later
|
// Will be enhanced with Ink UI later
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -114,7 +115,10 @@ program
|
|||||||
if (options.clearCache) {
|
if (options.clearCache) {
|
||||||
const { clearCrawlCache } = await import("./scraper.js");
|
const { clearCrawlCache } = await import("./scraper.js");
|
||||||
const domain = new URL(url).hostname;
|
const domain = new URL(url).hostname;
|
||||||
await clearCrawlCache(path.resolve(process.cwd(), options.crawlDir), domain);
|
await clearCrawlCache(
|
||||||
|
path.resolve(process.cwd(), options.crawlDir),
|
||||||
|
domain,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pages = await crawlSite(url, {
|
const pages = await crawlSite(url, {
|
||||||
@@ -128,15 +132,25 @@ program
|
|||||||
console.log("\n📊 Site Profile:");
|
console.log("\n📊 Site Profile:");
|
||||||
console.log(` Domain: ${profile.domain}`);
|
console.log(` Domain: ${profile.domain}`);
|
||||||
console.log(` Total Pages: ${profile.totalPages}`);
|
console.log(` Total Pages: ${profile.totalPages}`);
|
||||||
console.log(` Navigation: ${profile.navigation.map((n) => n.label).join(", ")}`);
|
console.log(
|
||||||
|
` Navigation: ${profile.navigation.map((n) => n.label).join(", ")}`,
|
||||||
|
);
|
||||||
console.log(` Features: ${profile.existingFeatures.join(", ") || "none"}`);
|
console.log(` Features: ${profile.existingFeatures.join(", ") || "none"}`);
|
||||||
console.log(` Services: ${profile.services.join(", ") || "none"}`);
|
console.log(` Services: ${profile.services.join(", ") || "none"}`);
|
||||||
console.log(` External Domains: ${profile.externalDomains.join(", ") || "none"}`);
|
console.log(
|
||||||
|
` External Domains: ${profile.externalDomains.join(", ") || "none"}`,
|
||||||
|
);
|
||||||
console.log(` Company: ${profile.companyInfo.name || "unbekannt"}`);
|
console.log(` Company: ${profile.companyInfo.name || "unbekannt"}`);
|
||||||
console.log(` Tax ID: ${profile.companyInfo.taxId || "unbekannt"}`);
|
console.log(` Tax ID: ${profile.companyInfo.taxId || "unbekannt"}`);
|
||||||
console.log(` Colors: ${profile.colors.join(", ")}`);
|
console.log(` Colors: ${profile.colors.join(", ")}`);
|
||||||
console.log(` Images Found: ${profile.images.length}`);
|
console.log(` Images Found: ${profile.images.length}`);
|
||||||
console.log(` Social: ${Object.entries(profile.socialLinks).map(([k, v]) => `${k}`).join(", ") || "none"}`);
|
console.log(
|
||||||
|
` Social: ${
|
||||||
|
Object.entries(profile.socialLinks)
|
||||||
|
.map(([_k, _v]) => `${_k}`)
|
||||||
|
.join(", ") || "none"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
|
||||||
const outputPath = path.join(
|
const outputPath = path.join(
|
||||||
path.resolve(process.cwd(), options.crawlDir),
|
path.resolve(process.cwd(), options.crawlDir),
|
||||||
|
|||||||
7
packages/concept-engine/src/dummy.test.ts
Normal file
7
packages/concept-engine/src/dummy.test.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("concept-engine", () => {
|
||||||
|
it("should pass", () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,10 +27,8 @@ interface LLMResponse {
|
|||||||
*/
|
*/
|
||||||
export function cleanJson(str: string): string {
|
export function cleanJson(str: string): string {
|
||||||
let cleaned = str.replace(/```json\n?|```/g, "").trim();
|
let cleaned = str.replace(/```json\n?|```/g, "").trim();
|
||||||
cleaned = cleaned.replace(
|
// eslint-disable-next-line no-control-regex
|
||||||
/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g,
|
cleaned = cleaned.replace(/[\x00-\x1f\x7f-\x9f]/gi, " ");
|
||||||
" ",
|
|
||||||
);
|
|
||||||
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
|
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
|
||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
@@ -38,12 +36,13 @@ export function cleanJson(str: string): string {
|
|||||||
/**
|
/**
|
||||||
* Send a request to an LLM via OpenRouter.
|
* Send a request to an LLM via OpenRouter.
|
||||||
*/
|
*/
|
||||||
export async function llmRequest(options: LLMRequestOptions): Promise<LLMResponse> {
|
export async function llmRequest(
|
||||||
|
options: LLMRequestOptions,
|
||||||
|
): Promise<LLMResponse> {
|
||||||
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
|
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
|
||||||
|
|
||||||
const startTime = Date.now();
|
const resp = await axios
|
||||||
|
.post(
|
||||||
const resp = await axios.post(
|
|
||||||
"https://openrouter.ai/api/v1/chat/completions",
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
{
|
{
|
||||||
model,
|
model,
|
||||||
@@ -60,9 +59,13 @@ export async function llmRequest(options: LLMRequestOptions): Promise<LLMRespons
|
|||||||
},
|
},
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
},
|
},
|
||||||
).catch(err => {
|
)
|
||||||
|
.catch((err) => {
|
||||||
if (err.response) {
|
if (err.response) {
|
||||||
console.error("OpenRouter API Error:", JSON.stringify(err.response.data, null, 2));
|
console.error(
|
||||||
|
"OpenRouter API Error:",
|
||||||
|
JSON.stringify(err.response.data, null, 2),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
@@ -125,7 +128,13 @@ function unwrapResponse(obj: any): any {
|
|||||||
const keys = Object.keys(obj);
|
const keys = Object.keys(obj);
|
||||||
if (keys.length === 1) {
|
if (keys.length === 1) {
|
||||||
const key = keys[0];
|
const key = keys[0];
|
||||||
if (key === "0" || key === "state" || key === "facts" || key === "result" || key === "data") {
|
if (
|
||||||
|
key === "0" ||
|
||||||
|
key === "state" ||
|
||||||
|
key === "facts" ||
|
||||||
|
key === "result" ||
|
||||||
|
key === "data"
|
||||||
|
) {
|
||||||
return unwrapResponse(obj[key]);
|
return unwrapResponse(obj[key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { crawlSite, clearCrawlCache } from "./scraper.js";
|
import { crawlSite, clearCrawlCache } from "./scraper.js";
|
||||||
import { analyzeSite } from "./analyzer.js";
|
import { analyzeSite } from "./analyzer.js";
|
||||||
import { executeResearch } from "./steps/00b-research.js";
|
import { executeResearch } from "./steps/00b-research.js";
|
||||||
@@ -20,7 +19,6 @@ import type {
|
|||||||
ConceptState,
|
ConceptState,
|
||||||
ProjectConcept,
|
ProjectConcept,
|
||||||
StepResult,
|
StepResult,
|
||||||
StepUsage,
|
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
export interface PipelineCallbacks {
|
export interface PipelineCallbacks {
|
||||||
@@ -74,7 +72,10 @@ export class ConceptPipeline {
|
|||||||
const domain = new URL(input.url).hostname;
|
const domain = new URL(input.url).hostname;
|
||||||
await clearCrawlCache(this.config.crawlDir, domain);
|
await clearCrawlCache(this.config.crawlDir, domain);
|
||||||
}
|
}
|
||||||
await this.runStep("00-scrape", "Scraping & Analyzing Website", async () => {
|
await this.runStep(
|
||||||
|
"00-scrape",
|
||||||
|
"Scraping & Analyzing Website",
|
||||||
|
async () => {
|
||||||
const pages = await crawlSite(input.url!, {
|
const pages = await crawlSite(input.url!, {
|
||||||
zyteApiKey: this.config.zyteApiKey,
|
zyteApiKey: this.config.zyteApiKey,
|
||||||
crawlDir: this.config.crawlDir,
|
crawlDir: this.config.crawlDir,
|
||||||
@@ -82,7 +83,10 @@ export class ConceptPipeline {
|
|||||||
const domain = new URL(input.url!).hostname;
|
const domain = new URL(input.url!).hostname;
|
||||||
const siteProfile = analyzeSite(pages, domain);
|
const siteProfile = analyzeSite(pages, domain);
|
||||||
this.state.siteProfile = siteProfile;
|
this.state.siteProfile = siteProfile;
|
||||||
this.state.crawlDir = path.join(this.config.crawlDir, domain.replace(/\./g, "-"));
|
this.state.crawlDir = path.join(
|
||||||
|
this.config.crawlDir,
|
||||||
|
domain.replace(/\./g, "-"),
|
||||||
|
);
|
||||||
|
|
||||||
// Save site profile
|
// Save site profile
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
@@ -93,42 +97,66 @@ export class ConceptPipeline {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: siteProfile,
|
data: siteProfile,
|
||||||
usage: { step: "00-scrape", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: 0 },
|
usage: {
|
||||||
|
step: "00-scrape",
|
||||||
|
model: "none",
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
cost: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 00a: Site Audit (DataForSEO)
|
// Step 00a: Site Audit (DataForSEO)
|
||||||
await this.runStep("00a-site-audit", "IST-Analysis (DataForSEO)", async () => {
|
await this.runStep(
|
||||||
|
"00a-site-audit",
|
||||||
|
"IST-Analysis (DataForSEO)",
|
||||||
|
async () => {
|
||||||
const result = await executeSiteAudit(this.state, this.config);
|
const result = await executeSiteAudit(this.state, this.config);
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
this.state.siteAudit = result.data;
|
this.state.siteAudit = result.data;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Step 00b: Research (real web data via journaling)
|
// Step 00b: Research (real web data via journaling)
|
||||||
await this.runStep("00b-research", "Industry & Company Research", async () => {
|
await this.runStep(
|
||||||
|
"00b-research",
|
||||||
|
"Industry & Company Research",
|
||||||
|
async () => {
|
||||||
const result = await executeResearch(this.state);
|
const result = await executeResearch(this.state);
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
this.state.researchData = result.data;
|
this.state.researchData = result.data;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Step 1: Extract facts
|
// Step 1: Extract facts
|
||||||
await this.runStep("01-extract", "Extracting Facts from Briefing", async () => {
|
await this.runStep(
|
||||||
|
"01-extract",
|
||||||
|
"Extracting Facts from Briefing",
|
||||||
|
async () => {
|
||||||
const result = await executeExtract(this.state, this.config);
|
const result = await executeExtract(this.state, this.config);
|
||||||
if (result.success) this.state.facts = result.data;
|
if (result.success) this.state.facts = result.data;
|
||||||
return result;
|
return result;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Step 2: Audit features
|
// Step 2: Audit features
|
||||||
await this.runStep("02-audit", "Auditing Features (Skeptical Review)", async () => {
|
await this.runStep(
|
||||||
|
"02-audit",
|
||||||
|
"Auditing Features (Skeptical Review)",
|
||||||
|
async () => {
|
||||||
const result = await executeAudit(this.state, this.config);
|
const result = await executeAudit(this.state, this.config);
|
||||||
if (result.success) this.state.auditedFacts = result.data;
|
if (result.success) this.state.auditedFacts = result.data;
|
||||||
return result;
|
return result;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Step 3: Strategic analysis
|
// Step 3: Strategic analysis
|
||||||
await this.runStep("03-strategize", "Strategic Analysis", async () => {
|
await this.runStep("03-strategize", "Strategic Analysis", async () => {
|
||||||
@@ -177,8 +205,12 @@ export class ConceptPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const cost = result.usage?.cost ? ` ($${result.usage.cost.toFixed(4)})` : "";
|
const cost = result.usage?.cost
|
||||||
const duration = result.usage?.durationMs ? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]` : "";
|
? ` ($${result.usage.cost.toFixed(4)})`
|
||||||
|
: "";
|
||||||
|
const duration = result.usage?.durationMs
|
||||||
|
? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]`
|
||||||
|
: "";
|
||||||
console.log(` ✅ ${stepName} complete${cost}${duration}`);
|
console.log(` ✅ ${stepName} complete${cost}${duration}`);
|
||||||
this.callbacks.onStepComplete?.(stepId, result);
|
this.callbacks.onStepComplete?.(stepId, result);
|
||||||
} else {
|
} else {
|
||||||
@@ -232,7 +264,10 @@ export class ConceptPipeline {
|
|||||||
console.log(`\n📦 Saved Project Concept to: ${statePath}`);
|
console.log(`\n📦 Saved Project Concept to: ${statePath}`);
|
||||||
|
|
||||||
// Save debug trace
|
// Save debug trace
|
||||||
const debugPath = path.join(stateDir, `${companyName}_${timestamp}_debug.json`);
|
const debugPath = path.join(
|
||||||
|
stateDir,
|
||||||
|
`${companyName}_${timestamp}_debug.json`,
|
||||||
|
);
|
||||||
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
|
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
|
||||||
|
|
||||||
// Print usage summary
|
// Print usage summary
|
||||||
@@ -241,12 +276,16 @@ export class ConceptPipeline {
|
|||||||
console.log("──────────────────────────────────────────────");
|
console.log("──────────────────────────────────────────────");
|
||||||
for (const step of this.state.usage.perStep) {
|
for (const step of this.state.usage.perStep) {
|
||||||
if (step.cost > 0) {
|
if (step.cost > 0) {
|
||||||
console.log(` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`);
|
console.log(
|
||||||
|
` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log("──────────────────────────────────────────────");
|
console.log("──────────────────────────────────────────────");
|
||||||
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
|
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
|
||||||
console.log(` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`);
|
console.log(
|
||||||
|
` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`,
|
||||||
|
);
|
||||||
console.log("──────────────────────────────────────────────\n");
|
console.log("──────────────────────────────────────────────\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,18 +21,48 @@ interface ScraperConfig {
|
|||||||
function classifyPage(pathname: string): PageType {
|
function classifyPage(pathname: string): PageType {
|
||||||
const p = pathname.toLowerCase();
|
const p = pathname.toLowerCase();
|
||||||
if (p === "/" || p === "" || p === "/index.html") return "home";
|
if (p === "/" || p === "" || p === "/index.html") return "home";
|
||||||
if (p.includes("service") || p.includes("leistung") || p.includes("kompetenz"))
|
if (
|
||||||
|
p.includes("service") ||
|
||||||
|
p.includes("leistung") ||
|
||||||
|
p.includes("kompetenz")
|
||||||
|
)
|
||||||
return "service";
|
return "service";
|
||||||
if (p.includes("about") || p.includes("ueber") || p.includes("über") || p.includes("unternehmen"))
|
if (
|
||||||
|
p.includes("about") ||
|
||||||
|
p.includes("ueber") ||
|
||||||
|
p.includes("über") ||
|
||||||
|
p.includes("unternehmen")
|
||||||
|
)
|
||||||
return "about";
|
return "about";
|
||||||
if (p.includes("contact") || p.includes("kontakt")) return "contact";
|
if (p.includes("contact") || p.includes("kontakt")) return "contact";
|
||||||
if (p.includes("job") || p.includes("karriere") || p.includes("career") || p.includes("human-resources"))
|
if (
|
||||||
|
p.includes("job") ||
|
||||||
|
p.includes("karriere") ||
|
||||||
|
p.includes("career") ||
|
||||||
|
p.includes("human-resources")
|
||||||
|
)
|
||||||
return "career";
|
return "career";
|
||||||
if (p.includes("portfolio") || p.includes("referenz") || p.includes("projekt") || p.includes("case-study"))
|
if (
|
||||||
|
p.includes("portfolio") ||
|
||||||
|
p.includes("referenz") ||
|
||||||
|
p.includes("projekt") ||
|
||||||
|
p.includes("case-study")
|
||||||
|
)
|
||||||
return "portfolio";
|
return "portfolio";
|
||||||
if (p.includes("blog") || p.includes("news") || p.includes("aktuelles") || p.includes("magazin"))
|
if (
|
||||||
|
p.includes("blog") ||
|
||||||
|
p.includes("news") ||
|
||||||
|
p.includes("aktuelles") ||
|
||||||
|
p.includes("magazin")
|
||||||
|
)
|
||||||
return "blog";
|
return "blog";
|
||||||
if (p.includes("legal") || p.includes("impressum") || p.includes("datenschutz") || p.includes("privacy") || p.includes("agb"))
|
if (
|
||||||
|
p.includes("legal") ||
|
||||||
|
p.includes("impressum") ||
|
||||||
|
p.includes("datenschutz") ||
|
||||||
|
p.includes("privacy") ||
|
||||||
|
p.includes("agb")
|
||||||
|
)
|
||||||
return "legal";
|
return "legal";
|
||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
@@ -62,14 +92,17 @@ function detectFeatures($: cheerio.CheerioAPI): string[] {
|
|||||||
|
|
||||||
// Maps
|
// Maps
|
||||||
if (
|
if (
|
||||||
$('iframe[src*="google.com/maps"], iframe[src*="openstreetmap"], .map-container, #map, [data-map]').length > 0
|
$(
|
||||||
|
'iframe[src*="google.com/maps"], iframe[src*="openstreetmap"], .map-container, #map, [data-map]',
|
||||||
|
).length > 0
|
||||||
) {
|
) {
|
||||||
features.push("maps");
|
features.push("maps");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video
|
// Video
|
||||||
if (
|
if (
|
||||||
$("video, iframe[src*='youtube'], iframe[src*='vimeo'], .video-container").length > 0
|
$("video, iframe[src*='youtube'], iframe[src*='vimeo'], .video-container")
|
||||||
|
.length > 0
|
||||||
) {
|
) {
|
||||||
features.push("video");
|
features.push("video");
|
||||||
}
|
}
|
||||||
@@ -80,7 +113,10 @@ function detectFeatures($: cheerio.CheerioAPI): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cookie consent
|
// Cookie consent
|
||||||
if ($(".cookie-banner, .cookie-consent, #cookie-notice, [data-cookie]").length > 0) {
|
if (
|
||||||
|
$(".cookie-banner, .cookie-consent, #cookie-notice, [data-cookie]").length >
|
||||||
|
0
|
||||||
|
) {
|
||||||
features.push("cookie-consent");
|
features.push("cookie-consent");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +135,12 @@ function extractInternalLinks($: cheerio.CheerioAPI, origin: string): string[] {
|
|||||||
const url = new URL(href, origin);
|
const url = new URL(href, origin);
|
||||||
if (url.origin === origin) {
|
if (url.origin === origin) {
|
||||||
// Skip assets
|
// Skip assets
|
||||||
if (/\.(pdf|zip|jpg|jpeg|png|svg|webp|gif|css|js|ico|woff|woff2|ttf|eot)$/i.test(url.pathname)) return;
|
if (
|
||||||
|
/\.(pdf|zip|jpg|jpeg|png|svg|webp|gif|css|js|ico|woff|woff2|ttf|eot)$/i.test(
|
||||||
|
url.pathname,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
// Skip anchors-only
|
// Skip anchors-only
|
||||||
if (url.pathname === "/" && url.hash) return;
|
if (url.pathname === "/" && url.hash) return;
|
||||||
links.push(url.pathname);
|
links.push(url.pathname);
|
||||||
@@ -139,7 +180,8 @@ function extractImages($: cheerio.CheerioAPI, origin: string): string[] {
|
|||||||
try {
|
try {
|
||||||
const url = new URL(img, origin);
|
const url = new URL(img, origin);
|
||||||
// Ignore small tracking pixels or generic vectors
|
// Ignore small tracking pixels or generic vectors
|
||||||
if (url.pathname.endsWith(".svg") && !url.pathname.includes("logo")) continue;
|
if (url.pathname.endsWith(".svg") && !url.pathname.includes("logo"))
|
||||||
|
continue;
|
||||||
absoluteImages.push(url.href);
|
absoluteImages.push(url.href);
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid URL
|
// Invalid URL
|
||||||
@@ -149,32 +191,15 @@ function extractImages($: cheerio.CheerioAPI, origin: string): string[] {
|
|||||||
return [...new Set(absoluteImages)];
|
return [...new Set(absoluteImages)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract services/competencies from text content.
|
|
||||||
*/
|
|
||||||
function extractServices(text: string): string[] {
|
|
||||||
const services: string[] = [];
|
|
||||||
// Common pattern: bulleted or newline-separated service lists
|
|
||||||
const lines = text.split(/\n/).map((l) => l.trim()).filter((l) => l.length > 3 && l.length < 100);
|
|
||||||
for (const line of lines) {
|
|
||||||
// Skip generic boilerplate
|
|
||||||
if (/cookie|datenschutz|impressum|copyright|©/i.test(line)) continue;
|
|
||||||
if (/^(tel|fax|e-mail|mobil|web|http)/i.test(line)) continue;
|
|
||||||
services.push(line);
|
|
||||||
}
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a page via Zyte API with browser rendering.
|
* Fetch a page via Zyte API with browser rendering.
|
||||||
*/
|
*/
|
||||||
async function fetchWithZyte(url: string, apiKey: string): Promise<string> {
|
async function fetchWithZyte(url: string, apiKey: string): Promise<string> {
|
||||||
try {
|
|
||||||
const auth = Buffer.from(`${apiKey}:`).toString("base64");
|
const auth = Buffer.from(`${apiKey}:`).toString("base64");
|
||||||
const resp = await fetch("https://api.zyte.com/v1/extract", {
|
const resp = await fetch("https://api.zyte.com/v1/extract", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Basic ${auth}`,
|
Authorization: `Basic ${auth}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -186,7 +211,9 @@ async function fetchWithZyte(url: string, apiKey: string): Promise<string> {
|
|||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const errorText = await resp.text();
|
const errorText = await resp.text();
|
||||||
console.error(` ❌ Zyte API error ${resp.status} for ${url}: ${errorText}`);
|
console.error(
|
||||||
|
` ❌ Zyte API error ${resp.status} for ${url}: ${errorText}`,
|
||||||
|
);
|
||||||
// Rate limited — wait and retry once
|
// Rate limited — wait and retry once
|
||||||
if (resp.status === 429) {
|
if (resp.status === 429) {
|
||||||
console.log(" ⏳ Rate limited, waiting 5s and retrying...");
|
console.log(" ⏳ Rate limited, waiting 5s and retrying...");
|
||||||
@@ -202,28 +229,21 @@ async function fetchWithZyte(url: string, apiKey: string): Promise<string> {
|
|||||||
console.warn(` ⚠️ Zyte returned empty browserHtml for ${url}`);
|
console.warn(` ⚠️ Zyte returned empty browserHtml for ${url}`);
|
||||||
}
|
}
|
||||||
return html;
|
return html;
|
||||||
} catch (err: any) {
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a page via simple HTTP GET (fallback).
|
* Fetch a page via simple HTTP GET (fallback).
|
||||||
*/
|
*/
|
||||||
async function fetchDirect(url: string): Promise<string> {
|
async function fetchDirect(url: string): Promise<string> {
|
||||||
try {
|
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent":
|
"User-Agent":
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||||
},
|
},
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
});
|
}).catch(() => null);
|
||||||
if (!resp.ok) return "";
|
|
||||||
|
if (!resp || !resp.ok) return "";
|
||||||
return await resp.text();
|
return await resp.text();
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,7 +274,8 @@ function parsePage(html: string, url: string): CrawledPage {
|
|||||||
const links = extractInternalLinks($, urlObj.origin);
|
const links = extractInternalLinks($, urlObj.origin);
|
||||||
const images = extractImages($, urlObj.origin);
|
const images = extractImages($, urlObj.origin);
|
||||||
|
|
||||||
const description = $('meta[name="description"]').attr("content") || undefined;
|
const description =
|
||||||
|
$('meta[name="description"]').attr("content") || undefined;
|
||||||
const ogTitle = $('meta[property="og:title"]').attr("content") || undefined;
|
const ogTitle = $('meta[property="og:title"]').attr("content") || undefined;
|
||||||
const ogImage = $('meta[property="og:image"]').attr("content") || undefined;
|
const ogImage = $('meta[property="og:image"]').attr("content") || undefined;
|
||||||
|
|
||||||
@@ -295,7 +316,9 @@ export async function crawlSite(
|
|||||||
return loadCrawlFromDisk(domainDir);
|
return loadCrawlFromDisk(domainDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔍 Crawling ${targetUrl} via ${config.zyteApiKey ? "Zyte API" : "direct HTTP"}...`);
|
console.log(
|
||||||
|
`🔍 Crawling ${targetUrl} via ${config.zyteApiKey ? "Zyte API" : "direct HTTP"}...`,
|
||||||
|
);
|
||||||
|
|
||||||
// Ensure output dir
|
// Ensure output dir
|
||||||
await fs.mkdir(domainDir, { recursive: true });
|
await fs.mkdir(domainDir, { recursive: true });
|
||||||
@@ -331,7 +354,10 @@ export async function crawlSite(
|
|||||||
pages.push(page);
|
pages.push(page);
|
||||||
|
|
||||||
// Save HTML + metadata to disk
|
// Save HTML + metadata to disk
|
||||||
const safeName = urlPath === "/" ? "index" : urlPath.replace(/\//g, "_").replace(/^_/, "");
|
const safeName =
|
||||||
|
urlPath === "/"
|
||||||
|
? "index"
|
||||||
|
: urlPath.replace(/\//g, "_").replace(/^_/, "");
|
||||||
await fs.writeFile(path.join(domainDir, `${safeName}.html`), html);
|
await fs.writeFile(path.join(domainDir, `${safeName}.html`), html);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(domainDir, `${safeName}.meta.json`),
|
path.join(domainDir, `${safeName}.meta.json`),
|
||||||
@@ -380,7 +406,9 @@ export async function crawlSite(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`✅ Crawled ${pages.length} pages for ${domain}. Saved to ${domainDir}`);
|
console.log(
|
||||||
|
`✅ Crawled ${pages.length} pages for ${domain}. Saved to ${domainDir}`,
|
||||||
|
);
|
||||||
return pages;
|
return pages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,14 +417,18 @@ export async function crawlSite(
|
|||||||
*/
|
*/
|
||||||
async function loadCrawlFromDisk(domainDir: string): Promise<CrawledPage[]> {
|
async function loadCrawlFromDisk(domainDir: string): Promise<CrawledPage[]> {
|
||||||
const files = await fs.readdir(domainDir);
|
const files = await fs.readdir(domainDir);
|
||||||
const metaFiles = files.filter((f) => f.endsWith(".meta.json") && f !== "_crawl_meta.json");
|
const metaFiles = files.filter(
|
||||||
|
(f) => f.endsWith(".meta.json") && f !== "_crawl_meta.json",
|
||||||
|
);
|
||||||
|
|
||||||
const pages: CrawledPage[] = [];
|
const pages: CrawledPage[] = [];
|
||||||
for (const metaFile of metaFiles) {
|
for (const metaFile of metaFiles) {
|
||||||
const baseName = metaFile.replace(".meta.json", "");
|
const baseName = metaFile.replace(".meta.json", "");
|
||||||
const htmlFile = `${baseName}.html`;
|
const htmlFile = `${baseName}.html`;
|
||||||
|
|
||||||
const meta = JSON.parse(await fs.readFile(path.join(domainDir, metaFile), "utf8"));
|
const meta = JSON.parse(
|
||||||
|
await fs.readFile(path.join(domainDir, metaFile), "utf8"),
|
||||||
|
);
|
||||||
let html = "";
|
let html = "";
|
||||||
if (files.includes(htmlFile)) {
|
if (files.includes(htmlFile)) {
|
||||||
html = await fs.readFile(path.join(domainDir, htmlFile), "utf8");
|
html = await fs.readFile(path.join(domainDir, htmlFile), "utf8");
|
||||||
@@ -434,7 +466,10 @@ async function loadCrawlFromDisk(domainDir: string): Promise<CrawledPage[]> {
|
|||||||
/**
|
/**
|
||||||
* Delete a cached crawl to force re-crawl.
|
* Delete a cached crawl to force re-crawl.
|
||||||
*/
|
*/
|
||||||
export async function clearCrawlCache(crawlDir: string, domain: string): Promise<void> {
|
export async function clearCrawlCache(
|
||||||
|
crawlDir: string,
|
||||||
|
domain: string,
|
||||||
|
): Promise<void> {
|
||||||
const domainDir = path.join(crawlDir, domain.replace(/\./g, "-"));
|
const domainDir = path.join(crawlDir, domain.replace(/\./g, "-"));
|
||||||
if (existsSync(domainDir)) {
|
if (existsSync(domainDir)) {
|
||||||
await fs.rm(domainDir, { recursive: true, force: true });
|
await fs.rm(domainDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -212,7 +212,11 @@ export class AiBlogPostOrchestrator {
|
|||||||
console.log(`✅ Saved optimized file to: ${finalPath}`);
|
console.log(`✅ Saved optimized file to: ${finalPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateSlug(content: string, title?: string, instructions?: string): Promise<string> {
|
async generateSlug(
|
||||||
|
content: string,
|
||||||
|
title?: string,
|
||||||
|
instructions?: string,
|
||||||
|
): Promise<string> {
|
||||||
const response = await this.openai.chat.completions.create({
|
const response = await this.openai.chat.completions.create({
|
||||||
model: "google/gemini-3-flash-preview",
|
model: "google/gemini-3-flash-preview",
|
||||||
messages: [
|
messages: [
|
||||||
@@ -223,21 +227,32 @@ Return ONLY a JSON object with a single string field "slug".
|
|||||||
Example: {"slug": "how-to-optimize-react-performance"}
|
Example: {"slug": "how-to-optimize-react-performance"}
|
||||||
Rules: Use lowercase letters, numbers, and hyphens only. No special characters. Keep it concise (2-5 words).`,
|
Rules: Use lowercase letters, numbers, and hyphens only. No special characters. Keep it concise (2-5 words).`,
|
||||||
},
|
},
|
||||||
{ role: "user", content: `Title: ${title || "Unknown"}\n\nContent:\n${content.slice(0, 3000)}...${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the slug:\n${instructions}` : ""}` },
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Title: ${title || "Unknown"}\n\nContent:\n${content.slice(0, 3000)}...${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the slug:\n${instructions}` : ""}`,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
response_format: { type: "json_object" },
|
response_format: { type: "json_object" },
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(response.choices[0].message.content || '{"slug": ""}');
|
const parsed = JSON.parse(
|
||||||
let slug = parsed.slug || "new-post";
|
response.choices[0].message.content || '{"slug": ""}',
|
||||||
return slug.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
);
|
||||||
|
const slug = parsed.slug || "new-post";
|
||||||
|
return slug
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
} catch {
|
} catch {
|
||||||
return "new-post";
|
return "new-post";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async generateVisualPrompt(content: string, instructions?: string): Promise<string> {
|
public async generateVisualPrompt(
|
||||||
|
content: string,
|
||||||
|
instructions?: string,
|
||||||
|
): Promise<string> {
|
||||||
const response = await this.openai.chat.completions.create({
|
const response = await this.openai.chat.completions.create({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -253,7 +268,10 @@ FOCUS: The core metaphor or technical concept of the article.
|
|||||||
|
|
||||||
Example output: "A complex network of glowing fiber optic nodes forming a recursive pyramid structure, technical blue lineart style."`,
|
Example output: "A complex network of glowing fiber optic nodes forming a recursive pyramid structure, technical blue lineart style."`,
|
||||||
},
|
},
|
||||||
{ role: "user", content: `${content.slice(0, 5000)}${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the visual prompt:\n${instructions}` : ""}` },
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `${content.slice(0, 5000)}${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the visual prompt:\n${instructions}` : ""}`,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
max_tokens: 100,
|
max_tokens: 100,
|
||||||
});
|
});
|
||||||
@@ -329,7 +347,7 @@ Example output: "A complex network of glowing fiber optic nodes forming a recurs
|
|||||||
);
|
);
|
||||||
const realPosts = await this.researchAgent.fetchRealSocialPosts(
|
const realPosts = await this.researchAgent.fetchRealSocialPosts(
|
||||||
task.content.slice(0, 500),
|
task.content.slice(0, 500),
|
||||||
task.customSources
|
task.customSources,
|
||||||
);
|
);
|
||||||
socialPosts.push(...realPosts);
|
socialPosts.push(...realPosts);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "customer-manager",
|
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
|
||||||
"icon": "extension",
|
|
||||||
"version": "1.8.21",
|
|
||||||
"type": "module",
|
|
||||||
"keywords": [
|
|
||||||
"directus",
|
|
||||||
"directus-extension",
|
|
||||||
"directus-extension-module"
|
|
||||||
],
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "module",
|
|
||||||
"path": "dist/index.js",
|
|
||||||
"source": "src/index.ts",
|
|
||||||
"host": "app",
|
|
||||||
"name": "customer manager"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "directus-extension build",
|
|
||||||
"dev": "directus-extension build -w"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mintel/directus-extension-toolkit": "workspace:*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,538 +0,0 @@
|
|||||||
<template>
|
|
||||||
<MintelManagerLayout
|
|
||||||
title="Customer Manager"
|
|
||||||
:item-title="selectedItem?.company?.name || 'Kunde wählen'"
|
|
||||||
:is-empty="!selectedItem"
|
|
||||||
empty-title="Kunde auswählen"
|
|
||||||
empty-icon="handshake"
|
|
||||||
:notice="notice"
|
|
||||||
@close-notice="notice = null"
|
|
||||||
>
|
|
||||||
<template #navigation>
|
|
||||||
<v-list nav>
|
|
||||||
<v-list-item @click="openCreateDrawer" 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="Neuen Kunden verlinken" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-list-item
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.id"
|
|
||||||
:active="selectedItem?.id === item.id"
|
|
||||||
class="nav-item"
|
|
||||||
clickable
|
|
||||||
@click="selectItem(item)"
|
|
||||||
>
|
|
||||||
<v-list-item-icon><v-icon name="business" /></v-list-item-icon>
|
|
||||||
<v-list-item-content>
|
|
||||||
<v-text-overflow :text="item.company?.name" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #subtitle>
|
|
||||||
<template v-if="selectedItem">
|
|
||||||
{{ clientUsers.length }} Portal-Nutzer · {{ selectedItem.company?.domain }}
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<v-button secondary rounded icon v-tooltip.bottom="'Kunden-Verlinkung bearbeiten'" @click="openEditDrawer">
|
|
||||||
<v-icon name="edit" />
|
|
||||||
</v-button>
|
|
||||||
<div @click="onDebugClick" style="display: inline-block; border: 2px solid lime;">
|
|
||||||
<v-button primary @click="openCreateClientUser">
|
|
||||||
Portal-Nutzer hinzufügen
|
|
||||||
</v-button>
|
|
||||||
</div>
|
|
||||||
<button style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG</button>
|
|
||||||
<button style="background: blue; color: white; padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="openCreateClientUser">NATIVE: Portal-Nutzer</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #empty-state>
|
|
||||||
Wähle einen Kunden aus der Liste oder
|
|
||||||
<v-button x-small @click="openCreateDrawer">verlinke eine neue Firma</v-button>.
|
|
||||||
<button id="debug-click-test" style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG CLICK</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Main Content: Client Users Table -->
|
|
||||||
<v-table
|
|
||||||
:headers="tableHeaders"
|
|
||||||
:items="clientUsers"
|
|
||||||
: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>
|
|
||||||
</v-table>
|
|
||||||
|
|
||||||
<!-- Drawer: Customer (Link) Form -->
|
|
||||||
<v-drawer
|
|
||||||
v-model="drawerActive"
|
|
||||||
:title="isEditing ? 'Kunden-Verlinkung bearbeiten' : 'Kunden verlinken'"
|
|
||||||
icon="handshake"
|
|
||||||
@cancel="drawerActive = false"
|
|
||||||
>
|
|
||||||
<div v-if="drawerActive" class="drawer-content">
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Organisation / Firma</span>
|
|
||||||
<MintelSelect
|
|
||||||
v-model="form.company"
|
|
||||||
:items="companyOptions"
|
|
||||||
placeholder="Firma auswählen..."
|
|
||||||
allow-add
|
|
||||||
@add="openQuickAdd('company')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Haupt-Ansprechpartner (optional)</span>
|
|
||||||
<MintelSelect
|
|
||||||
v-model="form.contact_person"
|
|
||||||
:items="peopleOptions"
|
|
||||||
placeholder="Person auswählen..."
|
|
||||||
allow-add
|
|
||||||
@add="openQuickAdd('person')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Status</span>
|
|
||||||
<v-select
|
|
||||||
v-model="form.status"
|
|
||||||
:items="[
|
|
||||||
{ text: 'Aktiv', value: 'active' },
|
|
||||||
{ text: 'Inaktiv', value: 'inactive' }
|
|
||||||
]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Notizen</span>
|
|
||||||
<v-textarea v-model="form.notes" placeholder="Besonderheiten zu diesem Kunden..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="drawer-actions">
|
|
||||||
<v-button primary block :loading="saving" @click="saveItem">Speichern</v-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-drawer>
|
|
||||||
|
|
||||||
<!-- Drawer: Client User Form -->
|
|
||||||
<v-drawer
|
|
||||||
v-model="drawerUserActive"
|
|
||||||
:title="isEditingUser ? 'Portal-Nutzer bearbeiten' : 'Neuen Portal-Nutzer anlegen'"
|
|
||||||
icon="person"
|
|
||||||
@cancel="drawerUserActive = false"
|
|
||||||
>
|
|
||||||
<div v-if="drawerUserActive" class="drawer-content">
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Vorname</span>
|
|
||||||
<v-input v-model="userForm.first_name" placeholder="Vorname" autofocus />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Nachname</span>
|
|
||||||
<v-input v-model="userForm.last_name" placeholder="Nachname" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">E-Mail</span>
|
|
||||||
<v-input v-model="userForm.email" placeholder="E-Mail Adresse" type="email" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Zentrale Person (Verknüpfung)</span>
|
|
||||||
<v-select
|
|
||||||
v-model="userForm.person"
|
|
||||||
:items="peopleOptions"
|
|
||||||
placeholder="Master-Person auswählen..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-divider v-if="isEditingUser" />
|
|
||||||
|
|
||||||
<div v-if="isEditingUser" class="field">
|
|
||||||
<span class="label">Temporäres Passwort</span>
|
|
||||||
<v-input v-model="userForm.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="saveClientUser">Daten speichern</v-button>
|
|
||||||
|
|
||||||
<template v-if="isEditingUser">
|
|
||||||
<v-divider />
|
|
||||||
<v-button
|
|
||||||
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
|
|
||||||
secondary
|
|
||||||
block
|
|
||||||
:loading="invitingId === userForm.id"
|
|
||||||
@click="inviteUser(userForm)"
|
|
||||||
>
|
|
||||||
<v-icon name="send" left /> Zugangsdaten senden
|
|
||||||
</v-button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-drawer>
|
|
||||||
|
|
||||||
<!-- Drawer: Quick Add Company -->
|
|
||||||
<v-drawer
|
|
||||||
v-model="quickAddCompanyActive"
|
|
||||||
title="Firma schnell anlegen"
|
|
||||||
icon="business"
|
|
||||||
@cancel="quickAddCompanyActive = false"
|
|
||||||
>
|
|
||||||
<div class="drawer-content">
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Firmenname</span>
|
|
||||||
<v-input v-model="quickCompanyForm.name" placeholder="z.B. Schmidt GmbH" autofocus />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Domain / Website</span>
|
|
||||||
<v-input v-model="quickCompanyForm.domain" placeholder="example.com" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="drawer-actions">
|
|
||||||
<v-button primary block :loading="savingQuick" @click="saveQuickCompany">Firma speichern</v-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-drawer>
|
|
||||||
|
|
||||||
<!-- Drawer: Quick Add Person -->
|
|
||||||
<v-drawer
|
|
||||||
v-model="quickAddPersonActive"
|
|
||||||
title="Person schnell anlegen"
|
|
||||||
icon="person"
|
|
||||||
@cancel="quickAddPersonActive = false"
|
|
||||||
>
|
|
||||||
<div class="drawer-content">
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Vorname</span>
|
|
||||||
<v-input v-model="quickPersonForm.first_name" placeholder="Vorname" autofocus />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Nachname</span>
|
|
||||||
<v-input v-model="quickPersonForm.last_name" placeholder="Nachname" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">E-Mail</span>
|
|
||||||
<v-input v-model="quickPersonForm.email" placeholder="email@example.com" type="email" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="drawer-actions">
|
|
||||||
<v-button primary block :loading="savingQuick" @click="saveQuickPerson">Person speichern</v-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-drawer>
|
|
||||||
</MintelManagerLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, nextTick, computed, watch } from 'vue';
|
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
|
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
function onDebugClick() {
|
|
||||||
console.log("=== [Customer Manager] DEBUG CLICK TRAPPED ===");
|
|
||||||
alert("Interactivity OK!");
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = ref<any[]>([]);
|
|
||||||
const selectedItem = ref<any>(null);
|
|
||||||
const clientUsers = 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);
|
|
||||||
|
|
||||||
const companies = ref<any[]>([]);
|
|
||||||
const people = ref<any[]>([]);
|
|
||||||
|
|
||||||
const drawerActive = ref(false);
|
|
||||||
const isEditing = ref(false);
|
|
||||||
const form = ref({ id: null, company: null, contact_person: null, status: 'active', notes: '' });
|
|
||||||
|
|
||||||
const drawerUserActive = ref(false);
|
|
||||||
const isEditingUser = ref(false);
|
|
||||||
const userForm = ref({ id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' });
|
|
||||||
|
|
||||||
const quickAddCompanyActive = ref(false);
|
|
||||||
const quickAddPersonActive = ref(false);
|
|
||||||
const savingQuick = ref(false);
|
|
||||||
const quickCompanyForm = ref({ name: '', domain: '' });
|
|
||||||
const quickPersonForm = 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 }
|
|
||||||
];
|
|
||||||
|
|
||||||
const companyOptions = computed(() => companies.value.map(c => ({ text: c.name, value: c.id })));
|
|
||||||
const peopleOptions = computed(() => people.value.map(p => ({ text: `${p.first_name} ${p.last_name} (${p.email})`, value: p.id })));
|
|
||||||
|
|
||||||
async function fetchData() {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const [custResp, compResp, peopleResp] = await Promise.all([
|
|
||||||
api.get('/items/customers', { params: { fields: ['*', 'company.*', 'contact_person.*'], sort: 'company.name' } }),
|
|
||||||
api.get('/items/companies', { params: { sort: 'name' } }),
|
|
||||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
|
||||||
]);
|
|
||||||
items.value = custResp.data.data;
|
|
||||||
companies.value = compResp.data.data;
|
|
||||||
people.value = peopleResp.data.data;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectItem(item: any) {
|
|
||||||
selectedItem.value = item;
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await api.get('/items/client_users', {
|
|
||||||
params: {
|
|
||||||
filter: { company: { _eq: item.company.id } },
|
|
||||||
fields: ['*', 'person.*'],
|
|
||||||
sort: 'first_name',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
clientUsers.value = res.data.data;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDrawer() {
|
|
||||||
isEditing.value = false;
|
|
||||||
form.value = { id: null, company: null, contact_person: null, status: 'active', notes: '' };
|
|
||||||
drawerActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditDrawer() {
|
|
||||||
if (!selectedItem.value) return;
|
|
||||||
isEditing.value = true;
|
|
||||||
form.value = {
|
|
||||||
id: selectedItem.value.id,
|
|
||||||
company: selectedItem.value.company?.id || selectedItem.value.company,
|
|
||||||
contact_person: selectedItem.value.contact_person?.id || selectedItem.value.contact_person,
|
|
||||||
status: selectedItem.value.status,
|
|
||||||
notes: selectedItem.value.notes
|
|
||||||
};
|
|
||||||
drawerActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveItem() {
|
|
||||||
if (!form.value.company) {
|
|
||||||
notice.value = { type: 'danger', message: 'Bitte wählen Sie eine Firma aus.' };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
if (isEditing.value) {
|
|
||||||
await api.patch(`/items/customers/${form.value.id}`, form.value);
|
|
||||||
notice.value = { type: 'success', message: 'Kunde aktualisiert!' };
|
|
||||||
} else {
|
|
||||||
await api.post('/items/customers', form.value);
|
|
||||||
notice.value = { type: 'success', message: 'Neuer Kunde verlinkt!' };
|
|
||||||
}
|
|
||||||
drawerActive.value = false;
|
|
||||||
await fetchData();
|
|
||||||
if (form.value.id) {
|
|
||||||
const updated = items.value.find(i => i.id === form.value.id);
|
|
||||||
if (updated) selectItem(updated);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
notice.value = {
|
|
||||||
type: 'danger',
|
|
||||||
message: e.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen'
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client User Actions
|
|
||||||
function openCreateClientUser() {
|
|
||||||
isEditingUser.value = false;
|
|
||||||
userForm.value = { id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' };
|
|
||||||
drawerUserActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRowClick(event: any) {
|
|
||||||
const item = event?.item || event;
|
|
||||||
if (item && item.id) {
|
|
||||||
userForm.value = {
|
|
||||||
id: item.id,
|
|
||||||
first_name: item.first_name,
|
|
||||||
last_name: item.last_name,
|
|
||||||
email: item.email,
|
|
||||||
person: item.person?.id || item.person,
|
|
||||||
temporary_password: item.temporary_password
|
|
||||||
};
|
|
||||||
isEditingUser.value = true;
|
|
||||||
drawerUserActive.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveClientUser() {
|
|
||||||
if (!userForm.value.email || !selectedItem.value) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
first_name: userForm.value.first_name,
|
|
||||||
last_name: userForm.value.last_name,
|
|
||||||
email: userForm.value.email,
|
|
||||||
person: userForm.value.person,
|
|
||||||
company: selectedItem.value.company.id
|
|
||||||
};
|
|
||||||
if (isEditingUser.value) {
|
|
||||||
await api.patch(`/items/client_users/${userForm.value.id}`, payload);
|
|
||||||
} else {
|
|
||||||
await api.post('/items/client_users', payload);
|
|
||||||
}
|
|
||||||
drawerUserActive.value = false;
|
|
||||||
await selectItem(selectedItem.value);
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inviteUser(user: any) {
|
|
||||||
invitingId.value = user.id;
|
|
||||||
try {
|
|
||||||
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
|
|
||||||
notice.value = { type: 'success', message: `Zugangsdaten versendet. 📧` };
|
|
||||||
await selectItem(selectedItem.value);
|
|
||||||
} finally {
|
|
||||||
invitingId.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openQuickAdd(type: 'company' | 'person') {
|
|
||||||
if (type === 'company') {
|
|
||||||
quickCompanyForm.value = { name: '', domain: '' };
|
|
||||||
quickAddCompanyActive.value = true;
|
|
||||||
} else {
|
|
||||||
quickPersonForm.value = { first_name: '', last_name: '', email: '' };
|
|
||||||
quickAddPersonActive.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveQuickCompany() {
|
|
||||||
if (!quickCompanyForm.value.name) return;
|
|
||||||
savingQuick.value = true;
|
|
||||||
try {
|
|
||||||
const res = await api.post('/items/companies', quickCompanyForm.value);
|
|
||||||
await fetchData();
|
|
||||||
form.value.company = res.data.data.id;
|
|
||||||
quickAddCompanyActive.value = false;
|
|
||||||
notice.value = { type: 'success', message: 'Firma angelegt und ausgewählt.' };
|
|
||||||
} catch (e: any) {
|
|
||||||
notice.value = { type: 'danger', message: e.message };
|
|
||||||
} finally {
|
|
||||||
savingQuick.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveQuickPerson() {
|
|
||||||
if (!quickPersonForm.value.first_name || !quickPersonForm.value.last_name) return;
|
|
||||||
savingQuick.value = true;
|
|
||||||
try {
|
|
||||||
const res = await api.post('/items/people', quickPersonForm.value);
|
|
||||||
await fetchData();
|
|
||||||
form.value.contact_person = res.data.data.id;
|
|
||||||
quickAddPersonActive.value = false;
|
|
||||||
notice.value = { type: 'success', message: 'Person angelegt und ausgewählt.' };
|
|
||||||
} catch (e: any) {
|
|
||||||
notice.value = { type: 'danger', message: e.message };
|
|
||||||
} finally {
|
|
||||||
savingQuick.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 handleDeepLink() {
|
|
||||||
if (route.query.create === 'true') {
|
|
||||||
// Only open if not already open to avoid resetting form if user is already typing
|
|
||||||
if (!drawerActive.value) {
|
|
||||||
openCreateDrawer();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route.query.company) {
|
|
||||||
form.value.company = route.query.company as any;
|
|
||||||
}
|
|
||||||
if (route.query.contact_person) {
|
|
||||||
form.value.contact_person = route.query.contact_person as any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => route.query.create, (newVal) => {
|
|
||||||
if (newVal === 'true') handleDeepLink();
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchData();
|
|
||||||
handleDeepLink();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.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);
|
|
||||||
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; }
|
|
||||||
</style>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@mintel/directus-extension-toolkit",
|
|
||||||
"version": "1.8.21",
|
|
||||||
"description": "Shared toolkit for Directus extensions in the Mintel ecosystem",
|
|
||||||
"type": "module",
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"module": "./dist/index.js",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "vite build",
|
|
||||||
"dev": "vite build --watch"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
|
||||||
"typescript": "^5.0.0",
|
|
||||||
"vite": "^5.0.0",
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@directus/extensions-sdk": "*",
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
<template>
|
|
||||||
<private-view :title="title">
|
|
||||||
<template #navigation>
|
|
||||||
<slot name="navigation" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #title-outer:after>
|
|
||||||
<v-notice v-if="notice" :type="notice.type" @close="$emit('close-notice')" dismissible>
|
|
||||||
{{ notice.message }}
|
|
||||||
</v-notice>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="mintel-manager-layout">
|
|
||||||
<div v-if="isEmpty" class="empty-state">
|
|
||||||
<v-info :title="emptyTitle" :icon="emptyIcon" center>
|
|
||||||
<slot name="empty-state" />
|
|
||||||
</v-info>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<header class="mintel-header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h1 class="mintel-title">{{ itemTitle }}</h1>
|
|
||||||
<p class="mintel-subtitle">
|
|
||||||
<slot name="subtitle" />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<div class="mintel-content">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</private-view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
title: string;
|
|
||||||
itemTitle?: string;
|
|
||||||
isEmpty?: boolean;
|
|
||||||
emptyTitle?: string;
|
|
||||||
emptyIcon?: string;
|
|
||||||
notice?: { type: string; message: string } | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
defineEmits(['close-notice']);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mintel-manager-layout {
|
|
||||||
padding: 32px;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mintel-header {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mintel-title {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 800;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: var(--theme--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mintel-subtitle {
|
|
||||||
color: var(--theme--foreground-subdued);
|
|
||||||
font-size: 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mintel-content {
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="mintel-select">
|
|
||||||
<v-select
|
|
||||||
:model-value="modelValue"
|
|
||||||
:items="items"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
:searchable="searchable"
|
|
||||||
:show-deselect="showDeselect"
|
|
||||||
@update:model-value="$emit('update:modelValue', $event)"
|
|
||||||
/>
|
|
||||||
<v-button v-if="allowAdd" secondary rounded icon x-small class="add-button" @click="$emit('add')">
|
|
||||||
<v-icon name="add" />
|
|
||||||
</v-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: null
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
type: Array as () => Array<{ text: string; value: string | number }>,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: 'Auswählen...'
|
|
||||||
},
|
|
||||||
searchable: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
showDeselect: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
allowAdd: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
defineEmits(['update:modelValue', 'add']);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mintel-select {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mintel-select :deep(.v-select) {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-button {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="mintel-stat-card" @click="$emit('click')">
|
|
||||||
<div class="stat-icon">
|
|
||||||
<v-icon :name="icon" large />
|
|
||||||
</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<span class="stat-label">{{ label }}</span>
|
|
||||||
<span class="stat-value">{{ value }}</span>
|
|
||||||
</div>
|
|
||||||
<v-icon name="chevron_right" class="arrow" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
icon: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
defineEmits(['click']);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mintel-stat-card {
|
|
||||||
background: var(--theme--background-normal);
|
|
||||||
border: 1px solid var(--theme--border);
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mintel-stat-card:hover {
|
|
||||||
border-color: var(--theme--primary);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
background: var(--theme--background-subdued);
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--theme--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--theme--foreground-subdued);
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--theme--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow {
|
|
||||||
position: absolute;
|
|
||||||
right: 24px;
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mintel-stat-card:hover .arrow {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--theme--primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { default as MintelSelect } from './MintelSelect.vue';
|
|
||||||
export { default as MintelManagerLayout } from './MintelManagerLayout.vue';
|
|
||||||
export { default as MintelStatCard } from './MintelStatCard.vue';
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import vue from '@vitejs/plugin-vue';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue()],
|
|
||||||
build: {
|
|
||||||
lib: {
|
|
||||||
entry: resolve('src/index.ts'),
|
|
||||||
name: 'MintelDirectusToolkit',
|
|
||||||
fileName: 'index',
|
|
||||||
formats: ['es']
|
|
||||||
},
|
|
||||||
rollupOptions: {
|
|
||||||
external: ['vue', '@directus/extensions-sdk'],
|
|
||||||
output: {
|
|
||||||
globals: {
|
|
||||||
vue: 'Vue',
|
|
||||||
'@directus/extensions-sdk': 'DirectusExtensionsSDK'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,26 +1,26 @@
|
|||||||
import { config as dotenvConfig } from 'dotenv';
|
import { config as dotenvConfig } from "dotenv";
|
||||||
import * as path from 'node:path';
|
import * as path from "node:path";
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from "node:fs/promises";
|
||||||
import { EstimationPipeline } from './pipeline.js';
|
import { EstimationPipeline } from "./pipeline.js";
|
||||||
|
|
||||||
dotenvConfig({ path: path.resolve(process.cwd(), '../../.env') });
|
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
|
||||||
|
|
||||||
const briefing = await fs.readFile(
|
const briefing = await fs.readFile(
|
||||||
path.resolve(process.cwd(), '../../data/briefings/etib.txt'),
|
path.resolve(process.cwd(), "../../data/briefings/etib.txt"),
|
||||||
'utf8',
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`Briefing loaded: ${briefing.length} chars`);
|
console.log(`Briefing loaded: ${briefing.length} chars`);
|
||||||
|
|
||||||
const pipeline = new EstimationPipeline(
|
const pipeline = new EstimationPipeline(
|
||||||
{
|
{
|
||||||
openrouterKey: process.env.OPENROUTER_API_KEY || '',
|
openrouterKey: process.env.OPENROUTER_API_KEY || "",
|
||||||
zyteApiKey: process.env.ZYTE_API_KEY,
|
zyteApiKey: process.env.ZYTE_API_KEY,
|
||||||
outputDir: path.resolve(process.cwd(), '../../out/estimations'),
|
outputDir: path.resolve(process.cwd(), "../../out/estimations"),
|
||||||
crawlDir: path.resolve(process.cwd(), '../../data/crawls'),
|
crawlDir: path.resolve(process.cwd(), "../../data/crawls"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onStepStart: (id, name) => console.log(`[CB] Starting: ${id}`),
|
onStepStart: (id, _name) => console.log(`[CB] Starting: ${id}`),
|
||||||
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
|
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
|
||||||
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
|
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
|
||||||
},
|
},
|
||||||
@@ -28,13 +28,35 @@ const pipeline = new EstimationPipeline(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pipeline.run({
|
const result = await pipeline.run({
|
||||||
briefing,
|
concept: {
|
||||||
url: 'https://www.e-tib.com',
|
strategy: {
|
||||||
|
briefingSummary: briefing,
|
||||||
|
projectGoals: [],
|
||||||
|
targetAudience: [],
|
||||||
|
coreMessage: "",
|
||||||
|
designVision: "",
|
||||||
|
uniqueValueProposition: "",
|
||||||
|
competitorAnalysis: "",
|
||||||
|
},
|
||||||
|
architecture: {
|
||||||
|
sitemap: [],
|
||||||
|
recommendedTechStack: [],
|
||||||
|
integrations: [],
|
||||||
|
websiteTopic: "",
|
||||||
|
dataModels: [],
|
||||||
|
},
|
||||||
|
auditedFacts: {
|
||||||
|
companyName: "E-TIB",
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('\n✨ Pipeline complete!');
|
console.log("\n✨ Pipeline complete!");
|
||||||
console.log('Validation:', result.validationResult?.passed ? 'PASSED' : 'FAILED');
|
console.log(
|
||||||
|
"Validation:",
|
||||||
|
result.validationResult?.passed ? "PASSED" : "FAILED",
|
||||||
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('\n❌ Pipeline failed:', err.message);
|
console.error("\n❌ Pipeline failed:", err.message);
|
||||||
console.error(err.stack);
|
console.error(err.stack);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ program
|
|||||||
.option("--budget <budget>", "Budget constraint (e.g. '15.000 €')")
|
.option("--budget <budget>", "Budget constraint (e.g. '15.000 €')")
|
||||||
.option("--output <dir>", "Output directory", "../../out/estimations")
|
.option("--output <dir>", "Output directory", "../../out/estimations")
|
||||||
.action(async (conceptFile: string, options: any) => {
|
.action(async (conceptFile: string, options: any) => {
|
||||||
const openrouterKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
const openrouterKey =
|
||||||
|
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||||
if (!openrouterKey) {
|
if (!openrouterKey) {
|
||||||
console.error("❌ OPENROUTER_API_KEY not found in environment.");
|
console.error("❌ OPENROUTER_API_KEY not found in environment.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -49,11 +50,11 @@ program
|
|||||||
{
|
{
|
||||||
openrouterKey,
|
openrouterKey,
|
||||||
outputDir: path.resolve(process.cwd(), options.output),
|
outputDir: path.resolve(process.cwd(), options.output),
|
||||||
crawlDir: "" // No longer needed here
|
crawlDir: "", // No longer needed here
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onStepStart: (id, name) => { },
|
onStepStart: (_id, _name) => {},
|
||||||
onStepComplete: (id, result) => { },
|
onStepComplete: (_id, _result) => {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -66,7 +67,9 @@ program
|
|||||||
console.log("\n✨ Estimation complete!");
|
console.log("\n✨ Estimation complete!");
|
||||||
|
|
||||||
if (result.validationResult && !result.validationResult.passed) {
|
if (result.validationResult && !result.validationResult.passed) {
|
||||||
console.log(`\n⚠️ ${result.validationResult.errors.length} validation issues found.`);
|
console.log(
|
||||||
|
`\n⚠️ ${result.validationResult.errors.length} validation issues found.`,
|
||||||
|
);
|
||||||
console.log(" Review the output JSON and re-run problematic steps.");
|
console.log(" Review the output JSON and re-run problematic steps.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -27,10 +27,8 @@ interface LLMResponse {
|
|||||||
*/
|
*/
|
||||||
export function cleanJson(str: string): string {
|
export function cleanJson(str: string): string {
|
||||||
let cleaned = str.replace(/```json\n?|```/g, "").trim();
|
let cleaned = str.replace(/```json\n?|```/g, "").trim();
|
||||||
cleaned = cleaned.replace(
|
// eslint-disable-next-line no-control-regex
|
||||||
/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g,
|
cleaned = cleaned.replace(/[\x00-\x1f\x7f-\x9f]/gi, " ");
|
||||||
" ",
|
|
||||||
);
|
|
||||||
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
|
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
|
||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
@@ -38,11 +36,11 @@ export function cleanJson(str: string): string {
|
|||||||
/**
|
/**
|
||||||
* Send a request to an LLM via OpenRouter.
|
* Send a request to an LLM via OpenRouter.
|
||||||
*/
|
*/
|
||||||
export async function llmRequest(options: LLMRequestOptions): Promise<LLMResponse> {
|
export async function llmRequest(
|
||||||
|
options: LLMRequestOptions,
|
||||||
|
): Promise<LLMResponse> {
|
||||||
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
|
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
"https://openrouter.ai/api/v1/chat/completions",
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
{
|
{
|
||||||
@@ -120,7 +118,13 @@ function unwrapResponse(obj: any): any {
|
|||||||
const keys = Object.keys(obj);
|
const keys = Object.keys(obj);
|
||||||
if (keys.length === 1) {
|
if (keys.length === 1) {
|
||||||
const key = keys[0];
|
const key = keys[0];
|
||||||
if (key === "0" || key === "state" || key === "facts" || key === "result" || key === "data") {
|
if (
|
||||||
|
key === "0" ||
|
||||||
|
key === "state" ||
|
||||||
|
key === "facts" ||
|
||||||
|
key === "result" ||
|
||||||
|
key === "data"
|
||||||
|
) {
|
||||||
return unwrapResponse(obj[key]);
|
return unwrapResponse(obj[key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { validateEstimation } from "./validators.js";
|
import { validateEstimation } from "./validators.js";
|
||||||
import { executeSynthesize } from "./steps/05-synthesize.js";
|
import { executeSynthesize } from "./steps/05-synthesize.js";
|
||||||
import { executeCritique } from "./steps/06-critique.js";
|
import { executeCritique } from "./steps/06-critique.js";
|
||||||
@@ -14,7 +13,6 @@ import type {
|
|||||||
PipelineInput,
|
PipelineInput,
|
||||||
EstimationState,
|
EstimationState,
|
||||||
StepResult,
|
StepResult,
|
||||||
StepUsage,
|
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
export interface PipelineCallbacks {
|
export interface PipelineCallbacks {
|
||||||
@@ -68,11 +66,15 @@ export class EstimationPipeline {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Step 6: Quality critique
|
// Step 6: Quality critique
|
||||||
await this.runStep("06-critique", "Quality Gate (Industrial Critic)", async () => {
|
await this.runStep(
|
||||||
|
"06-critique",
|
||||||
|
"Quality Gate (Industrial Critic)",
|
||||||
|
async () => {
|
||||||
const result = await executeCritique(this.state, this.config);
|
const result = await executeCritique(this.state, this.config);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
this.state.critiquePassed = result.data.passed;
|
this.state.critiquePassed = result.data.passed;
|
||||||
this.state.critiqueErrors = result.data.errors?.map((e: any) => `${e.field}: ${e.issue}`) || [];
|
this.state.critiqueErrors =
|
||||||
|
result.data.errors?.map((e: any) => `${e.field}: ${e.issue}`) || [];
|
||||||
|
|
||||||
// Apply corrections
|
// Apply corrections
|
||||||
if (result.data.corrections) {
|
if (result.data.corrections) {
|
||||||
@@ -88,7 +90,8 @@ export class EstimationPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Step 7: Deterministic validation
|
// Step 7: Deterministic validation
|
||||||
await this.runStep("07-validate", "Deterministic Validation", async () => {
|
await this.runStep("07-validate", "Deterministic Validation", async () => {
|
||||||
@@ -114,7 +117,14 @@ export class EstimationPipeline {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: validationResult,
|
data: validationResult,
|
||||||
usage: { step: "07-validate", model: "none", promptTokens: 0, completionTokens: 0, cost: 0, durationMs: 0 },
|
usage: {
|
||||||
|
step: "07-validate",
|
||||||
|
model: "none",
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
cost: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,8 +155,12 @@ export class EstimationPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const cost = result.usage?.cost ? ` ($${result.usage.cost.toFixed(4)})` : "";
|
const cost = result.usage?.cost
|
||||||
const duration = result.usage?.durationMs ? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]` : "";
|
? ` ($${result.usage.cost.toFixed(4)})`
|
||||||
|
: "";
|
||||||
|
const duration = result.usage?.durationMs
|
||||||
|
? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]`
|
||||||
|
: "";
|
||||||
console.log(` ✅ ${stepName} complete${cost}${duration}`);
|
console.log(` ✅ ${stepName} complete${cost}${duration}`);
|
||||||
this.callbacks.onStepComplete?.(stepId, result);
|
this.callbacks.onStepComplete?.(stepId, result);
|
||||||
} else {
|
} else {
|
||||||
@@ -173,7 +187,10 @@ export class EstimationPipeline {
|
|||||||
designVision: this.state.concept.strategy.designVision || "",
|
designVision: this.state.concept.strategy.designVision || "",
|
||||||
sitemap: this.state.concept.architecture.sitemap || [],
|
sitemap: this.state.concept.architecture.sitemap || [],
|
||||||
positionDescriptions: this.state.positionDescriptions || {},
|
positionDescriptions: this.state.positionDescriptions || {},
|
||||||
websiteTopic: this.state.concept.architecture.websiteTopic || facts.websiteTopic || "",
|
websiteTopic:
|
||||||
|
this.state.concept.architecture.websiteTopic ||
|
||||||
|
facts.websiteTopic ||
|
||||||
|
"",
|
||||||
statusQuo: facts.isRelaunch ? "Relaunch" : "Neuentwicklung",
|
statusQuo: facts.isRelaunch ? "Relaunch" : "Neuentwicklung",
|
||||||
name: facts.personName || "",
|
name: facts.personName || "",
|
||||||
email: facts.email || "",
|
email: facts.email || "",
|
||||||
@@ -185,18 +202,25 @@ export class EstimationPipeline {
|
|||||||
*/
|
*/
|
||||||
private async saveState(): Promise<void> {
|
private async saveState(): Promise<void> {
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
const companyName = this.state.concept.auditedFacts?.companyName || "unknown";
|
const companyName =
|
||||||
|
this.state.concept.auditedFacts?.companyName || "unknown";
|
||||||
|
|
||||||
// Save full state
|
// Save full state
|
||||||
const stateDir = path.join(this.config.outputDir, "json");
|
const stateDir = path.join(this.config.outputDir, "json");
|
||||||
await fs.mkdir(stateDir, { recursive: true });
|
await fs.mkdir(stateDir, { recursive: true });
|
||||||
|
|
||||||
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
|
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
|
||||||
await fs.writeFile(statePath, JSON.stringify(this.state.formState, null, 2));
|
await fs.writeFile(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify(this.state.formState, null, 2),
|
||||||
|
);
|
||||||
console.log(`\n📦 Saved state to: ${statePath}`);
|
console.log(`\n📦 Saved state to: ${statePath}`);
|
||||||
|
|
||||||
// Save full pipeline state (for debugging / re-entry)
|
// Save full pipeline state (for debugging / re-entry)
|
||||||
const debugPath = path.join(stateDir, `${companyName}_${timestamp}_debug.json`);
|
const debugPath = path.join(
|
||||||
|
stateDir,
|
||||||
|
`${companyName}_${timestamp}_debug.json`,
|
||||||
|
);
|
||||||
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
|
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
|
||||||
|
|
||||||
// Print usage summary
|
// Print usage summary
|
||||||
@@ -205,12 +229,16 @@ export class EstimationPipeline {
|
|||||||
console.log("──────────────────────────────────────────────");
|
console.log("──────────────────────────────────────────────");
|
||||||
for (const step of this.state.usage.perStep) {
|
for (const step of this.state.usage.perStep) {
|
||||||
if (step.cost > 0) {
|
if (step.cost > 0) {
|
||||||
console.log(` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`);
|
console.log(
|
||||||
|
` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log("──────────────────────────────────────────────");
|
console.log("──────────────────────────────────────────────");
|
||||||
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
|
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
|
||||||
console.log(` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`);
|
console.log(
|
||||||
|
` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`,
|
||||||
|
);
|
||||||
console.log("──────────────────────────────────────────────\n");
|
console.log("──────────────────────────────────────────────\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// @mintel/estimation-engine — Core Type Definitions
|
// @mintel/estimation-engine — Core Type Definitions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import type { ProjectConcept, SitemapCategory } from "@mintel/concept-engine";
|
import type { ProjectConcept } from "@mintel/concept-engine";
|
||||||
|
|
||||||
/** Configuration for the estimation pipeline */
|
/** Configuration for the estimation pipeline */
|
||||||
export interface PipelineConfig {
|
export interface PipelineConfig {
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
// Catches all the issues reported by the user programmatically.
|
// Catches all the issues reported by the user programmatically.
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import type { EstimationState, ValidationResult, ValidationError, ValidationWarning } from "./types.js";
|
import type {
|
||||||
|
EstimationState,
|
||||||
|
ValidationResult,
|
||||||
|
ValidationError,
|
||||||
|
ValidationWarning,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run all deterministic validation checks on the final estimation state.
|
* Run all deterministic validation checks on the final estimation state.
|
||||||
@@ -13,7 +18,16 @@ export function validateEstimation(state: EstimationState): ValidationResult {
|
|||||||
const warnings: ValidationWarning[] = [];
|
const warnings: ValidationWarning[] = [];
|
||||||
|
|
||||||
if (!state.formState) {
|
if (!state.formState) {
|
||||||
return { passed: false, errors: [{ code: "NO_FORM_STATE", message: "No form state available for validation." }], warnings: [] };
|
return {
|
||||||
|
passed: false,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
code: "NO_FORM_STATE",
|
||||||
|
message: "No form state available for validation.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const fs = state.formState;
|
const fs = state.formState;
|
||||||
@@ -77,7 +91,10 @@ function validatePageCountParity(
|
|||||||
|
|
||||||
// Extract page names mentioned in the "Individuelle Seiten" position description
|
// Extract page names mentioned in the "Individuelle Seiten" position description
|
||||||
const positions = fs.positionDescriptions || {};
|
const positions = fs.positionDescriptions || {};
|
||||||
const pagesDesc = positions["Individuelle Seiten"] || positions["2. Individuelle Seiten"] || "";
|
const pagesDesc =
|
||||||
|
positions["Individuelle Seiten"] ||
|
||||||
|
positions["2. Individuelle Seiten"] ||
|
||||||
|
"";
|
||||||
if (!pagesDesc) return;
|
if (!pagesDesc) return;
|
||||||
|
|
||||||
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
|
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
|
||||||
@@ -85,7 +102,9 @@ function validatePageCountParity(
|
|||||||
// Count distinct page names mentioned (split by comma)
|
// Count distinct page names mentioned (split by comma)
|
||||||
// We avoid splitting by "&" or "und" because actual page names like
|
// We avoid splitting by "&" or "und" because actual page names like
|
||||||
// "Wartung & Störungsdienst" or "Genehmigungs- und Ausführungsplanung" contain them.
|
// "Wartung & Störungsdienst" or "Genehmigungs- und Ausführungsplanung" contain them.
|
||||||
const afterColon = descStr.includes(":") ? descStr.split(":").slice(1).join(":") : descStr;
|
const afterColon = descStr.includes(":")
|
||||||
|
? descStr.split(":").slice(1).join(":")
|
||||||
|
: descStr;
|
||||||
const segments = afterColon
|
const segments = afterColon
|
||||||
.split(/,/)
|
.split(/,/)
|
||||||
.map((s: string) => s.replace(/\.$/, "").trim())
|
.map((s: string) => s.replace(/\.$/, "").trim())
|
||||||
@@ -123,7 +142,7 @@ function validatePageCountParity(
|
|||||||
function validateSorglosBetrieb(
|
function validateSorglosBetrieb(
|
||||||
fs: Record<string, any>,
|
fs: Record<string, any>,
|
||||||
errors: ValidationError[],
|
errors: ValidationError[],
|
||||||
warnings: ValidationWarning[],
|
_warnings: ValidationWarning[],
|
||||||
): void {
|
): void {
|
||||||
const positions = fs.positionDescriptions || {};
|
const positions = fs.positionDescriptions || {};
|
||||||
const hasPosition = Object.keys(positions).some(
|
const hasPosition = Object.keys(positions).some(
|
||||||
@@ -152,7 +171,9 @@ function validateNoVideosAsPages(
|
|||||||
): void {
|
): void {
|
||||||
const allPages = [...(fs.selectedPages || []), ...(fs.otherPages || [])];
|
const allPages = [...(fs.selectedPages || []), ...(fs.otherPages || [])];
|
||||||
const sitemapPages = Array.isArray(fs.sitemap)
|
const sitemapPages = Array.isArray(fs.sitemap)
|
||||||
? fs.sitemap.flatMap((cat: any) => (cat.pages || []).map((p: any) => p.title))
|
? fs.sitemap.flatMap((cat: any) =>
|
||||||
|
(cat.pages || []).map((p: any) => p.title),
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const allPageNames = [...allPages, ...sitemapPages];
|
const allPageNames = [...allPages, ...sitemapPages];
|
||||||
@@ -160,7 +181,11 @@ function validateNoVideosAsPages(
|
|||||||
|
|
||||||
for (const pageName of allPageNames) {
|
for (const pageName of allPageNames) {
|
||||||
const lower = (typeof pageName === "string" ? pageName : "").toLowerCase();
|
const lower = (typeof pageName === "string" ? pageName : "").toLowerCase();
|
||||||
if (videoKeywords.some((kw) => lower.includes(kw) && !lower.includes("leistung"))) {
|
if (
|
||||||
|
videoKeywords.some(
|
||||||
|
(kw) => lower.includes(kw) && !lower.includes("leistung"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
errors.push({
|
errors.push({
|
||||||
code: "VIDEO_AS_PAGE",
|
code: "VIDEO_AS_PAGE",
|
||||||
message: `"${pageName}" ist ein Video-Asset, keine eigene Seite.`,
|
message: `"${pageName}" ist ein Video-Asset, keine eigene Seite.`,
|
||||||
@@ -182,17 +207,25 @@ function validateExternalDomains(
|
|||||||
if (!siteProfile?.externalDomains?.length) return;
|
if (!siteProfile?.externalDomains?.length) return;
|
||||||
|
|
||||||
const sitemapPages = Array.isArray(fs.sitemap)
|
const sitemapPages = Array.isArray(fs.sitemap)
|
||||||
? fs.sitemap.flatMap((cat: any) => (cat.pages || []).map((p: any) => p.title || ""))
|
? fs.sitemap.flatMap((cat: any) =>
|
||||||
|
(cat.pages || []).map((p: any) => p.title || ""),
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
for (const extDomain of siteProfile.externalDomains) {
|
for (const extDomain of siteProfile.externalDomains) {
|
||||||
// Extract base name (e.g. "etib-ing" from "etib-ing.com")
|
// Extract base name (e.g. "etib-ing" from "etib-ing.com")
|
||||||
const baseName = extDomain.replace(/^www\./, "").split(".")[0].toLowerCase();
|
const baseName = extDomain
|
||||||
|
.replace(/^www\./, "")
|
||||||
|
.split(".")[0]
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
for (const pageTitle of sitemapPages) {
|
for (const pageTitle of sitemapPages) {
|
||||||
const lower = pageTitle.toLowerCase();
|
const lower = pageTitle.toLowerCase();
|
||||||
// Check if the page title references the external company
|
// Check if the page title references the external company
|
||||||
if (lower.includes(baseName) || (lower.includes("ingenieur") && extDomain.includes("ing"))) {
|
if (
|
||||||
|
lower.includes(baseName) ||
|
||||||
|
(lower.includes("ingenieur") && extDomain.includes("ing"))
|
||||||
|
) {
|
||||||
errors.push({
|
errors.push({
|
||||||
code: "EXTERNAL_DOMAIN_AS_PAGE",
|
code: "EXTERNAL_DOMAIN_AS_PAGE",
|
||||||
message: `"${pageTitle}" hat eine eigene Website (${extDomain}) und darf nicht als Unterseite vorgeschlagen werden.`,
|
message: `"${pageTitle}" hat eine eigene Website (${extDomain}) und darf nicht als Unterseite vorgeschlagen werden.`,
|
||||||
@@ -278,7 +311,10 @@ function validateMultilangLabeling(
|
|||||||
const positions = fs.positionDescriptions || {};
|
const positions = fs.positionDescriptions || {};
|
||||||
|
|
||||||
for (const [key, desc] of Object.entries(positions)) {
|
for (const [key, desc] of Object.entries(positions)) {
|
||||||
if (key.toLowerCase().includes("api") || key.toLowerCase().includes("schnittstelle")) {
|
if (
|
||||||
|
key.toLowerCase().includes("api") ||
|
||||||
|
key.toLowerCase().includes("schnittstelle")
|
||||||
|
) {
|
||||||
const descStr = typeof desc === "string" ? desc : "";
|
const descStr = typeof desc === "string" ? desc : "";
|
||||||
if (
|
if (
|
||||||
descStr.toLowerCase().includes("mehrsprach") ||
|
descStr.toLowerCase().includes("mehrsprach") ||
|
||||||
@@ -306,9 +342,15 @@ function validateInitialPflegeUnits(
|
|||||||
const positions = fs.positionDescriptions || {};
|
const positions = fs.positionDescriptions || {};
|
||||||
|
|
||||||
for (const [key, desc] of Object.entries(positions)) {
|
for (const [key, desc] of Object.entries(positions)) {
|
||||||
if (key.toLowerCase().includes("pflege") || key.toLowerCase().includes("initial")) {
|
if (
|
||||||
|
key.toLowerCase().includes("pflege") ||
|
||||||
|
key.toLowerCase().includes("initial")
|
||||||
|
) {
|
||||||
const descStr = typeof desc === "string" ? desc : "";
|
const descStr = typeof desc === "string" ? desc : "";
|
||||||
if (descStr.toLowerCase().includes("seiten") && !descStr.toLowerCase().includes("datensätz")) {
|
if (
|
||||||
|
descStr.toLowerCase().includes("seiten") &&
|
||||||
|
!descStr.toLowerCase().includes("datensätz")
|
||||||
|
) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
code: "INITIALPFLEGE_WRONG_UNIT",
|
code: "INITIALPFLEGE_WRONG_UNIT",
|
||||||
message: `"${key}" spricht von "Seiten", aber gemeint sind Datensätze (z.B. Produkte, Referenzen).`,
|
message: `"${key}" spricht von "Seiten", aber gemeint sind Datensätze (z.B. Produkte, Referenzen).`,
|
||||||
@@ -322,6 +364,10 @@ function validateInitialPflegeUnits(
|
|||||||
/**
|
/**
|
||||||
* 9. Position descriptions must match calculated quantities.
|
* 9. Position descriptions must match calculated quantities.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 9. Position descriptions must match calculated quantities.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
function validatePositionDescriptionsMath(
|
function validatePositionDescriptionsMath(
|
||||||
fs: Record<string, any>,
|
fs: Record<string, any>,
|
||||||
errors: ValidationError[],
|
errors: ValidationError[],
|
||||||
@@ -329,7 +375,10 @@ function validatePositionDescriptionsMath(
|
|||||||
const positions = fs.positionDescriptions || {};
|
const positions = fs.positionDescriptions || {};
|
||||||
|
|
||||||
// Check pages description mentions correct count
|
// Check pages description mentions correct count
|
||||||
const pagesDesc = positions["Individuelle Seiten"] || positions["2. Individuelle Seiten"] || "";
|
const pagesDesc =
|
||||||
|
positions["Individuelle Seiten"] ||
|
||||||
|
positions["2. Individuelle Seiten"] ||
|
||||||
|
"";
|
||||||
if (pagesDesc) {
|
if (pagesDesc) {
|
||||||
// Use the sitemap as the authoritative source of truth for page count
|
// Use the sitemap as the authoritative source of truth for page count
|
||||||
let sitemapPageCount = 0;
|
let sitemapPageCount = 0;
|
||||||
@@ -341,9 +390,15 @@ function validatePositionDescriptionsMath(
|
|||||||
|
|
||||||
// Count how many page names are mentioned in the description
|
// Count how many page names are mentioned in the description
|
||||||
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
|
const descStr = typeof pagesDesc === "string" ? pagesDesc : "";
|
||||||
const mentionedPages = descStr.split(/,|und|&/).filter((s: string) => s.trim().length > 2);
|
const mentionedPages = descStr
|
||||||
|
.split(/,|und|&/)
|
||||||
|
.filter((s: string) => s.trim().length > 2);
|
||||||
|
|
||||||
if (sitemapPageCount > 0 && mentionedPages.length > 0 && Math.abs(mentionedPages.length - sitemapPageCount) > 2) {
|
if (
|
||||||
|
sitemapPageCount > 0 &&
|
||||||
|
mentionedPages.length > 0 &&
|
||||||
|
Math.abs(mentionedPages.length - sitemapPageCount) > 2
|
||||||
|
) {
|
||||||
errors.push({
|
errors.push({
|
||||||
code: "PAGES_DESC_COUNT_MISMATCH",
|
code: "PAGES_DESC_COUNT_MISMATCH",
|
||||||
message: `Seiten-Beschreibung nennt ~${mentionedPages.length} Seiten, aber ${sitemapPageCount} in der Sitemap.`,
|
message: `Seiten-Beschreibung nennt ~${mentionedPages.length} Seiten, aber ${sitemapPageCount} in der Sitemap.`,
|
||||||
@@ -364,8 +419,9 @@ function validateSitemapConsistency(
|
|||||||
): void {
|
): void {
|
||||||
if (!Array.isArray(fs.sitemap)) return;
|
if (!Array.isArray(fs.sitemap)) return;
|
||||||
|
|
||||||
const sitemapTitles = fs.sitemap
|
const sitemapTitles = fs.sitemap.flatMap((cat: any) =>
|
||||||
.flatMap((cat: any) => (cat.pages || []).map((p: any) => (p.title || "").toLowerCase()));
|
(cat.pages || []).map((p: any) => (p.title || "").toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
// Check for "Verwaltung" page (hallucinated management page)
|
// Check for "Verwaltung" page (hallucinated management page)
|
||||||
for (const title of sitemapTitles) {
|
for (const title of sitemapTitles) {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "feedback-commander",
|
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
|
||||||
"icon": "extension",
|
|
||||||
"version": "1.8.21",
|
|
||||||
"type": "module",
|
|
||||||
"keywords": [
|
|
||||||
"directus",
|
|
||||||
"directus-extension",
|
|
||||||
"directus-extension-module"
|
|
||||||
],
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "module",
|
|
||||||
"path": "dist/index.js",
|
|
||||||
"source": "src/index.ts",
|
|
||||||
"host": "app",
|
|
||||||
"name": "feedback commander"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "directus-extension build",
|
|
||||||
"dev": "directus-extension build -w"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,731 +0,0 @@
|
|||||||
<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.company?.name || 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 || 'System' }}</span>
|
|
||||||
<span v-if="reply.person" class="reply-person">({{ reply.person.first_name }} {{ reply.person.last_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="business" x-small /> Organisation / Firma</label>
|
|
||||||
<strong>{{ selectedItem.company?.name || selectedItem.project }}</strong>
|
|
||||||
</div>
|
|
||||||
<div v-if="selectedItem.person" class="meta-item">
|
|
||||||
<label><v-icon name="person" x-small /> Zentrale Person</label>
|
|
||||||
<strong>{{ selectedItem.person.first_name }} {{ selectedItem.person.last_name }}</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.company?.name || i.project).filter(Boolean));
|
|
||||||
return Array.from(projSet).sort();
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
|
||||||
return items.value.filter(item => {
|
|
||||||
const projectName = item.company?.name || item.project;
|
|
||||||
const matchProject = currentProject.value === 'all' || projectName === 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,
|
|
||||||
fields: ['*', 'company.*', 'person.*']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
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',
|
|
||||||
fields: ['*', 'person.*']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
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>
|
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tensorflow/tfjs": "^4.22.0",
|
"@tensorflow/tfjs": "^4.22.0",
|
||||||
|
"@tensorflow/tfjs-backend-wasm": "^4.22.0",
|
||||||
"@vladmandic/face-api": "^1.7.15",
|
"@vladmandic/face-api": "^1.7.15",
|
||||||
"canvas": "^3.2.1",
|
"canvas": "^3.2.1",
|
||||||
"sharp": "^0.33.2"
|
"sharp": "^0.33.2"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Canvas, Image, ImageData } from "canvas";
|
|||||||
// Use the ESM no-bundle build to avoid the default Node entrypoint
|
// Use the ESM no-bundle build to avoid the default Node entrypoint
|
||||||
// which hardcodes require('@tensorflow/tfjs-node') and crashes in Docker.
|
// which hardcodes require('@tensorflow/tfjs-node') and crashes in Docker.
|
||||||
// This build uses pure @tensorflow/tfjs (JS-only, no native C++ bindings).
|
// This build uses pure @tensorflow/tfjs (JS-only, no native C++ bindings).
|
||||||
// @ts-ignore - direct path import has no type declarations
|
|
||||||
import * as faceapi from "@vladmandic/face-api/dist/face-api.esm-nobundle.js";
|
import * as faceapi from "@vladmandic/face-api/dist/face-api.esm-nobundle.js";
|
||||||
import * as tf from "@tensorflow/tfjs";
|
import * as tf from "@tensorflow/tfjs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|||||||
@@ -7,13 +7,11 @@ export default defineConfig({
|
|||||||
clean: true,
|
clean: true,
|
||||||
// Bundle face-api and tensorflow inline (they're pure JS).
|
// Bundle face-api and tensorflow inline (they're pure JS).
|
||||||
// Keep sharp and canvas external (they have native C++ bindings).
|
// Keep sharp and canvas external (they have native C++ bindings).
|
||||||
noExternal: [
|
noExternal: ["@vladmandic/face-api", "@tensorflow/tfjs"],
|
||||||
"@vladmandic/face-api",
|
|
||||||
"@tensorflow/tfjs",
|
|
||||||
"@tensorflow/tfjs-backend-wasm"
|
|
||||||
],
|
|
||||||
external: [
|
external: [
|
||||||
"sharp",
|
"sharp",
|
||||||
"canvas"
|
"canvas",
|
||||||
|
"@tensorflow/tfjs-backend-wasm",
|
||||||
|
"@tensorflow/tfjs-backend-wasm/dist/index.js",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"templates"
|
"templates"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@directus/sdk": "^21.0.0",
|
|
||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
"@mintel/tsconfig": "workspace:*",
|
"@mintel/tsconfig": "workspace:*",
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import {
|
|
||||||
createMintelDirectusClient,
|
|
||||||
ensureDirectusAuthenticated,
|
|
||||||
} from "@mintel/next-utils";
|
|
||||||
import { updateSettings } from "@directus/sdk";
|
|
||||||
|
|
||||||
const client = createMintelDirectusClient();
|
|
||||||
|
|
||||||
async function setupBranding() {
|
|
||||||
const prjName = process.env.PROJECT_NAME || "Mintel Project";
|
|
||||||
const prjColor = process.env.PROJECT_COLOR || "#82ed20";
|
|
||||||
|
|
||||||
console.log(`🎨 Setup Directus Branding for ${prjName}...`);
|
|
||||||
await ensureDirectusAuthenticated(client);
|
|
||||||
|
|
||||||
const cssInjection = `
|
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
|
||||||
body, .v-app { font-family: 'Inter', sans-serif !important; }
|
|
||||||
.public-view .v-card {
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border-radius: 32px !important;
|
|
||||||
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
|
|
||||||
}
|
|
||||||
.v-navigation-drawer { background: #000c24 !important; }
|
|
||||||
</style>
|
|
||||||
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
|
|
||||||
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">MINTEL INFRASTRUCTURE ENGINE</p>
|
|
||||||
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">${prjName.toUpperCase()} <span style="color: ${prjColor};">RELIABILITY.</span></h1>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.request(
|
|
||||||
updateSettings({
|
|
||||||
project_name: prjName,
|
|
||||||
project_color: prjColor,
|
|
||||||
public_note: cssInjection,
|
|
||||||
theme_light_overrides: {
|
|
||||||
primary: prjColor,
|
|
||||||
borderRadius: "16px",
|
|
||||||
navigationBackground: "#000c24",
|
|
||||||
navigationForeground: "#ffffff",
|
|
||||||
},
|
|
||||||
} as any),
|
|
||||||
);
|
|
||||||
console.log("✨ Branding applied!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Error setting up branding:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setupBranding();
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import {
|
|
||||||
createMintelDirectusClient,
|
|
||||||
ensureDirectusAuthenticated,
|
|
||||||
} from "@mintel/next-utils";
|
|
||||||
|
|
||||||
const client = createMintelDirectusClient(process.env.DIRECTUS_URL);
|
|
||||||
|
|
||||||
export async function ensureAuthenticated() {
|
|
||||||
await ensureDirectusAuthenticated(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default client;
|
|
||||||
@@ -42,7 +42,7 @@ export class DataCommonsClient {
|
|||||||
/**
|
/**
|
||||||
* Search for entities (places, etc.)
|
* Search for entities (places, etc.)
|
||||||
*/
|
*/
|
||||||
async resolveEntity(name: string): Promise<string | null> {
|
async resolveEntity(_name: string): Promise<string | null> {
|
||||||
// Search API or simple mapping for now.
|
// Search API or simple mapping for now.
|
||||||
// DC doesn't have a simple "search" endpoint in v2 public API easily accessible without key sometimes?
|
// DC doesn't have a simple "search" endpoint in v2 public API easily accessible without key sometimes?
|
||||||
// Let's rely on LLM to provide DCIDs for now, or implement a naive search if needed.
|
// Let's rely on LLM to provide DCIDs for now, or implement a naive search if needed.
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ Rules:
|
|||||||
|
|
||||||
async getRelatedQueries(
|
async getRelatedQueries(
|
||||||
keyword: string,
|
keyword: string,
|
||||||
geo: string = "DE",
|
_geo: string = "DE",
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
// Simple mock to avoid API calls
|
// Simple mock to avoid API calls
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^21.0.0",
|
|
||||||
"@medv/finder": "^4.0.2",
|
"@medv/finder": "^4.0.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^11.5.4",
|
"framer-motion": "^11.5.4",
|
||||||
|
|||||||
@@ -1,105 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
|
||||||
createDirectus,
|
|
||||||
rest,
|
|
||||||
staticToken,
|
|
||||||
createItem,
|
|
||||||
readItems,
|
|
||||||
} from "@directus/sdk";
|
|
||||||
|
|
||||||
export interface CMSConfig {
|
export interface CMSConfig {
|
||||||
url: string;
|
url: string;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCMSClient(config: CMSConfig) {
|
|
||||||
return createDirectus(config.url)
|
|
||||||
.with(staticToken(config.token))
|
|
||||||
.with(rest());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleFeedbackRequest(
|
export async function handleFeedbackRequest(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
config: CMSConfig,
|
_config: CMSConfig,
|
||||||
) {
|
) {
|
||||||
const client = createCMSClient(config);
|
// Feedback functionality has been disabled as Directus was removed
|
||||||
|
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
try {
|
return NextResponse.json([]);
|
||||||
const items = await client.request(
|
|
||||||
readItems("visual_feedback", {
|
|
||||||
fields: ["*", { comments: ["*"] }],
|
|
||||||
sort: ["-date_created"],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return NextResponse.json(items);
|
|
||||||
} catch (error: any) {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
try {
|
return NextResponse.json(
|
||||||
const body = await req.json();
|
{ error: "Feedback system disabled" },
|
||||||
const { action, screenshot_base64, ...data } = body;
|
{ status: 501 },
|
||||||
|
|
||||||
if (action === "reply") {
|
|
||||||
const reply = await client.request(
|
|
||||||
createItem("visual_feedback_comments", {
|
|
||||||
feedback_id: data.feedbackId,
|
|
||||||
user_name: data.userName,
|
|
||||||
text: data.text,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
return NextResponse.json(reply);
|
|
||||||
}
|
|
||||||
|
|
||||||
let screenshotId = null;
|
|
||||||
|
|
||||||
if (screenshot_base64) {
|
|
||||||
try {
|
|
||||||
const base64Data = screenshot_base64.split(";base64,").pop();
|
|
||||||
const buffer = Buffer.from(base64Data, "base64");
|
|
||||||
const formData = new FormData();
|
|
||||||
const blob = new Blob([buffer], { type: "image/png" });
|
|
||||||
formData.append("file", blob, `feedback-${Date.now()}.png`);
|
|
||||||
|
|
||||||
const fileRes = await fetch(`${config.url}/files`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${config.token}` },
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fileRes.ok) {
|
|
||||||
const fileData = await fileRes.json();
|
|
||||||
screenshotId = fileData.data.id;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to upload screenshot:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const feedback = await client.request(
|
|
||||||
createItem("visual_feedback", {
|
|
||||||
project: data.project || req.headers.get("host") || "unknown",
|
|
||||||
url: data.url,
|
|
||||||
selector: data.selector,
|
|
||||||
x: data.x,
|
|
||||||
y: data.y,
|
|
||||||
type: data.type,
|
|
||||||
text: data.text,
|
|
||||||
user_name: data.userName,
|
|
||||||
user_identity: data.userIdentity,
|
|
||||||
status: "open",
|
|
||||||
screenshot: screenshotId,
|
|
||||||
company: data.companyId,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(feedback);
|
|
||||||
} catch (error: any) {
|
|
||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
|
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^21.0.0",
|
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-intl": "^4.8.2",
|
"next-intl": "^4.8.2",
|
||||||
"zod": "^3.0.0"
|
"zod": "^3.0.0"
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import {
|
|
||||||
createDirectus,
|
|
||||||
rest,
|
|
||||||
authentication,
|
|
||||||
DirectusClient,
|
|
||||||
RestClient,
|
|
||||||
AuthenticationClient,
|
|
||||||
} from "@directus/sdk";
|
|
||||||
|
|
||||||
export type MintelDirectusClient<Schema extends object = any> =
|
|
||||||
DirectusClient<Schema> & RestClient<Schema> & AuthenticationClient<Schema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a Directus client configured with Mintel standards.
|
|
||||||
* Automatically handles internal vs. external URLs based on environment.
|
|
||||||
*/
|
|
||||||
export function createMintelDirectusClient<Schema extends object = any>(
|
|
||||||
url?: string,
|
|
||||||
): MintelDirectusClient<Schema> {
|
|
||||||
const isServer = typeof window === "undefined";
|
|
||||||
|
|
||||||
// 1. If an explicit URL is provided, use it.
|
|
||||||
if (url) {
|
|
||||||
return createDirectus<Schema>(url).with(rest()).with(authentication());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. On server: Prioritize INTERNAL_DIRECTUS_URL, fallback to DIRECTUS_URL
|
|
||||||
if (isServer) {
|
|
||||||
const directusUrl =
|
|
||||||
process.env.INTERNAL_DIRECTUS_URL ||
|
|
||||||
process.env.DIRECTUS_URL ||
|
|
||||||
"http://localhost:8055";
|
|
||||||
return createDirectus<Schema>(directusUrl)
|
|
||||||
.with(rest())
|
|
||||||
.with(authentication());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. In browser: Use a proxy path if we are on a different origin,
|
|
||||||
// or use the current origin if no DIRECTUS_URL is set.
|
|
||||||
const proxyPath = "/api/directus"; // Standard Mintel proxy path
|
|
||||||
const browserUrl =
|
|
||||||
typeof window !== "undefined"
|
|
||||||
? `${window.location.origin}${proxyPath}`
|
|
||||||
: proxyPath;
|
|
||||||
|
|
||||||
return createDirectus<Schema>(browserUrl).with(rest()).with(authentication());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures the client is authenticated using either a static token or admin credentials
|
|
||||||
*/
|
|
||||||
export async function ensureDirectusAuthenticated(
|
|
||||||
client: MintelDirectusClient,
|
|
||||||
) {
|
|
||||||
const token = process.env.DIRECTUS_API_TOKEN || process.env.DIRECTUS_TOKEN;
|
|
||||||
const email = process.env.DIRECTUS_ADMIN_EMAIL;
|
|
||||||
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
client.setToken(token);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (email && password) {
|
|
||||||
try {
|
|
||||||
await client.login({ email, password });
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to authenticate with Directus:", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,4 +34,3 @@ export * from "./lang";
|
|||||||
|
|
||||||
export * from "./i18n";
|
export * from "./i18n";
|
||||||
export * from "./env";
|
export * from "./env";
|
||||||
export * from "./directus";
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
"dev": "node build.mjs --watch"
|
"dev": "node build.mjs --watch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "people-manager",
|
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
|
||||||
"icon": "extension",
|
|
||||||
"version": "1.8.21",
|
|
||||||
"type": "module",
|
|
||||||
"keywords": [
|
|
||||||
"directus",
|
|
||||||
"directus-extension",
|
|
||||||
"directus-extension-module"
|
|
||||||
],
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "module",
|
|
||||||
"path": "dist/index.js",
|
|
||||||
"source": "src/index.ts",
|
|
||||||
"host": "app",
|
|
||||||
"name": "people manager"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "directus-extension build",
|
|
||||||
"dev": "directus-extension build -w"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mintel/directus-extension-toolkit": "workspace:*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { defineModule } from '@directus/extensions-sdk';
|
|
||||||
import ModuleComponent from './module.vue';
|
|
||||||
|
|
||||||
export default defineModule({
|
|
||||||
id: 'people-manager',
|
|
||||||
name: 'People Manager',
|
|
||||||
icon: 'person',
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
component: ModuleComponent,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
<template>
|
|
||||||
<MintelManagerLayout
|
|
||||||
title="People Manager"
|
|
||||||
:item-title="`${selectedPerson?.first_name} ${selectedPerson?.last_name}` || 'Person wählen'"
|
|
||||||
:is-empty="!selectedPerson"
|
|
||||||
empty-title="Person auswählen"
|
|
||||||
empty-icon="person"
|
|
||||||
:notice="feedback"
|
|
||||||
@close-notice="feedback = null"
|
|
||||||
>
|
|
||||||
<template #navigation>
|
|
||||||
<v-list nav>
|
|
||||||
<v-list-item @click="openCreateDrawer" 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 Person anlegen" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-list-item
|
|
||||||
v-for="person in people"
|
|
||||||
:key="person.id"
|
|
||||||
:active="selectedPerson?.id === person.id"
|
|
||||||
class="nav-item"
|
|
||||||
clickable
|
|
||||||
@click="selectPerson(person)"
|
|
||||||
>
|
|
||||||
<v-list-item-icon>
|
|
||||||
<v-icon name="person" />
|
|
||||||
</v-list-item-icon>
|
|
||||||
<v-list-item-content>
|
|
||||||
<v-text-overflow :text="`${person.first_name} ${person.last_name}`" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #subtitle>
|
|
||||||
<template v-if="selectedPerson">
|
|
||||||
{{ getCompanyName(selectedPerson) }}
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<v-button secondary rounded icon v-tooltip.bottom="'Person bearbeiten'" @click="openEditDrawer">
|
|
||||||
<v-icon name="edit" />
|
|
||||||
</v-button>
|
|
||||||
<v-button danger rounded icon v-tooltip.bottom="'Person löschen'" @click="deletePerson">
|
|
||||||
<v-icon name="delete" />
|
|
||||||
</v-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #empty-state>
|
|
||||||
Wähle eine Person in der Navigation aus oder
|
|
||||||
<v-button x-small @click="openCreateDrawer">erstelle eine neue Person</v-button>.
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="selectedPerson" class="details-grid">
|
|
||||||
<div class="detail-item">
|
|
||||||
<span class="label">Vorname</span>
|
|
||||||
<p class="value">{{ selectedPerson.first_name }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<span class="label">Nachname</span>
|
|
||||||
<p class="value">{{ selectedPerson.last_name }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<span class="label">E-Mail</span>
|
|
||||||
<p class="value">{{ selectedPerson.email || '---' }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<span class="label">Organisation</span>
|
|
||||||
<p class="value">{{ getCompanyName(selectedPerson) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Create/Edit Drawer -->
|
|
||||||
<v-drawer
|
|
||||||
v-model="drawerActive"
|
|
||||||
:title="isEditing ? 'Person bearbeiten' : 'Neue Person anlegen'"
|
|
||||||
icon="person"
|
|
||||||
@cancel="drawerActive = false"
|
|
||||||
>
|
|
||||||
<template #default>
|
|
||||||
<div class="drawer-content">
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Vorname</span>
|
|
||||||
<v-input v-model="form.first_name" placeholder="Vorname" autofocus />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Nachname</span>
|
|
||||||
<v-input v-model="form.last_name" placeholder="Nachname" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">E-Mail</span>
|
|
||||||
<v-input v-model="form.email" placeholder="E-Mail Adresse" type="email" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Zentrale Firma</span>
|
|
||||||
<MintelSelect
|
|
||||||
v-model="form.company"
|
|
||||||
:items="companyOptions"
|
|
||||||
placeholder="Bestehende Firma auswählen..."
|
|
||||||
allow-add
|
|
||||||
@add="openQuickAdd('company')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="drawer-actions">
|
|
||||||
<v-button primary block :loading="saving" @click="savePerson">
|
|
||||||
Person speichern
|
|
||||||
</v-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-drawer>
|
|
||||||
</MintelManagerLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed, nextTick } from 'vue';
|
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
|
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
const route = useRoute();
|
|
||||||
const people = ref([]);
|
|
||||||
const companies = ref([]);
|
|
||||||
const selectedPerson = ref(null);
|
|
||||||
const feedback = ref(null);
|
|
||||||
const saving = ref(false);
|
|
||||||
const drawerActive = ref(false);
|
|
||||||
const isEditing = ref(false);
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
id: null,
|
|
||||||
first_name: '',
|
|
||||||
last_name: '',
|
|
||||||
email: '',
|
|
||||||
company: null
|
|
||||||
});
|
|
||||||
|
|
||||||
const companyOptions = computed(() =>
|
|
||||||
companies.value.map(c => ({
|
|
||||||
text: c.name,
|
|
||||||
value: c.id
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
function getCompanyName(person: any) {
|
|
||||||
if (!person) return '---';
|
|
||||||
if (person.company) {
|
|
||||||
return typeof person.company === 'object' ? person.company.name : (companies.value.find(c => c.id === person.company)?.name || 'Unbekannte Firma');
|
|
||||||
}
|
|
||||||
return '---';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchData() {
|
|
||||||
try {
|
|
||||||
const [peopleResp, companiesResp] = await Promise.all([
|
|
||||||
api.get('/items/people', {
|
|
||||||
params: {
|
|
||||||
sort: 'last_name',
|
|
||||||
fields: '*.*'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
api.get('/items/companies', {
|
|
||||||
params: { sort: 'name' }
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
people.value = peopleResp.data.data;
|
|
||||||
companies.value = companiesResp.data.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPerson(person) {
|
|
||||||
selectedPerson.value = person;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDrawer() {
|
|
||||||
isEditing.value = false;
|
|
||||||
form.value = {
|
|
||||||
id: null,
|
|
||||||
first_name: '',
|
|
||||||
last_name: '',
|
|
||||||
email: '',
|
|
||||||
company: null
|
|
||||||
};
|
|
||||||
drawerActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditDrawer() {
|
|
||||||
isEditing.value = true;
|
|
||||||
const person = selectedPerson.value;
|
|
||||||
form.value = {
|
|
||||||
id: person.id,
|
|
||||||
first_name: person.first_name,
|
|
||||||
last_name: person.last_name,
|
|
||||||
email: person.email,
|
|
||||||
company: person.company?.id || person.company
|
|
||||||
};
|
|
||||||
drawerActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePerson() {
|
|
||||||
if (!form.value.first_name || !form.value.last_name) {
|
|
||||||
feedback.value = { type: 'danger', message: 'Vor- und Nachname sind erforderlich.' };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
let updatedItem;
|
|
||||||
if (isEditing.value) {
|
|
||||||
const res = await api.patch(`/items/people/${form.value.id}`, form.value);
|
|
||||||
updatedItem = res.data.data;
|
|
||||||
feedback.value = { type: 'success', message: 'Person aktualisiert!' };
|
|
||||||
} else {
|
|
||||||
const res = await api.post('/items/people', form.value);
|
|
||||||
updatedItem = res.data.data;
|
|
||||||
feedback.value = { type: 'success', message: 'Person angelegt!' };
|
|
||||||
}
|
|
||||||
drawerActive.value = false;
|
|
||||||
await fetchData();
|
|
||||||
if (updatedItem) {
|
|
||||||
selectedPerson.value = people.value.find(p => p.id === updatedItem.id) || updatedItem;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
feedback.value = { type: 'danger', message: error.message };
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deletePerson() {
|
|
||||||
if (!confirm('Soll diese Person wirklich gelöscht werden?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.delete(`/items/people/${selectedPerson.value.id}`);
|
|
||||||
feedback.value = { type: 'success', message: 'Person gelöscht.' };
|
|
||||||
selectedPerson.value = null;
|
|
||||||
await fetchData();
|
|
||||||
} catch (error) {
|
|
||||||
feedback.value = { type: 'danger', message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openQuickAdd(type: string) {
|
|
||||||
feedback.value = { type: 'info', message: `Firma im Company Manager anlegen.` };
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await fetchData();
|
|
||||||
if (route.query.create === 'true') {
|
|
||||||
openCreateDrawer();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.details-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-actions {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "unified-dashboard",
|
|
||||||
"description": "Custom High-Fidelity Management for Directus",
|
|
||||||
"icon": "extension",
|
|
||||||
"version": "1.8.21",
|
|
||||||
"type": "module",
|
|
||||||
"keywords": [
|
|
||||||
"directus",
|
|
||||||
"directus-extension",
|
|
||||||
"directus-extension-module"
|
|
||||||
],
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "module",
|
|
||||||
"path": "dist/index.js",
|
|
||||||
"source": "src/index.ts",
|
|
||||||
"host": "app",
|
|
||||||
"name": "unified dashboard"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "directus-extension build",
|
|
||||||
"dev": "directus-extension build -w"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { defineModule } from '@directus/extensions-sdk';
|
|
||||||
import ModuleComponent from './module.vue';
|
|
||||||
|
|
||||||
export default defineModule({
|
|
||||||
id: 'unified-dashboard',
|
|
||||||
name: 'Overview',
|
|
||||||
icon: 'dashboard',
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
component: ModuleComponent,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
<template>
|
|
||||||
<private-view title="Overview">
|
|
||||||
<div class="dashboard">
|
|
||||||
<header class="dashboard-header">
|
|
||||||
<h1 class="title">Infrastructure Stack</h1>
|
|
||||||
<p class="subtitle">Zentrale Schnittstelle für Firmen, Personen und Leads.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card" @click="navigateTo('company-manager')">
|
|
||||||
<div class="stat-icon"><v-icon name="business" large /></div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<span class="stat-label">Firmen</span>
|
|
||||||
<span class="stat-value">{{ stats.companies }}</span>
|
|
||||||
</div>
|
|
||||||
<v-icon name="chevron_right" class="arrow" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card" @click="navigateTo('people-manager')">
|
|
||||||
<div class="stat-icon"><v-icon name="person" large /></div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<span class="stat-label">Personen</span>
|
|
||||||
<span class="stat-value">{{ stats.people }}</span>
|
|
||||||
</div>
|
|
||||||
<v-icon name="chevron_right" class="arrow" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card" @click="navigateTo('acquisition-manager')">
|
|
||||||
<div class="stat-icon"><v-icon name="auto_awesome" large /></div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<span class="stat-label">Leads</span>
|
|
||||||
<span class="stat-value">{{ stats.leads }}</span>
|
|
||||||
</div>
|
|
||||||
<v-icon name="chevron_right" class="arrow" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="recent-activity">
|
|
||||||
<h2 class="section-title">Schnellzugriff</h2>
|
|
||||||
<div class="action-grid">
|
|
||||||
<v-button secondary block @click="navigateTo('people-manager', { create: 'true' })">
|
|
||||||
<v-icon name="person_add" left />
|
|
||||||
Neue Person anlegen
|
|
||||||
</v-button>
|
|
||||||
<v-button secondary block @click="navigateTo('acquisition-manager', { create: 'true' })">
|
|
||||||
<v-icon name="add_link" left />
|
|
||||||
Neuen Lead registrieren
|
|
||||||
</v-button>
|
|
||||||
<v-button secondary block @click="navigateTo('customer-manager', { create: 'true' })">
|
|
||||||
<v-icon name="handshake" left />
|
|
||||||
Kunden verlinken
|
|
||||||
</v-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</private-view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const stats = ref({
|
|
||||||
companies: 0,
|
|
||||||
people: 0,
|
|
||||||
leads: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchStats() {
|
|
||||||
try {
|
|
||||||
const [comp, peop, lead] = await Promise.all([
|
|
||||||
api.get('/items/companies?aggregate[count]=*'),
|
|
||||||
api.get('/items/people?aggregate[count]=*'),
|
|
||||||
api.get('/items/leads?aggregate[count]=*')
|
|
||||||
]);
|
|
||||||
stats.value = {
|
|
||||||
companies: comp.data.data[0].count,
|
|
||||||
people: peop.data.data[0].count,
|
|
||||||
leads: lead.data.data[0].count
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch stats:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateTo(id: string, query?: any) {
|
|
||||||
console.log(`[Unified Dashboard] Navigating to ${id}...`);
|
|
||||||
router.push({ name: id, query });
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(fetchStats);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.dashboard { padding: 40px; }
|
|
||||||
.dashboard-header { margin-bottom: 48px; }
|
|
||||||
.title { font-size: 32px; font-weight: 800; letter-spacing: -0.5px; margin-bottom: 8px; }
|
|
||||||
.subtitle { color: var(--theme--foreground-subdued); font-size: 16px; }
|
|
||||||
|
|
||||||
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; margin-bottom: 48px; }
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--theme--background-normal);
|
|
||||||
border: 1px solid var(--theme--border);
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:hover {
|
|
||||||
border-color: var(--theme--primary);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
background: var(--theme--background-subdued);
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--theme--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-content { display: flex; flex-direction: column; }
|
|
||||||
.stat-label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
|
||||||
.stat-value { font-size: 28px; font-weight: 800; color: var(--theme--foreground); }
|
|
||||||
|
|
||||||
.arrow { position: absolute; right: 24px; opacity: 0.2; }
|
|
||||||
.stat-card:hover .arrow { opacity: 1; color: var(--theme--primary); }
|
|
||||||
|
|
||||||
.recent-activity { max-width: 600px; }
|
|
||||||
.section-title { font-size: 18px; font-weight: 700; margin-bottom: 24px; }
|
|
||||||
.action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
||||||
</style>
|
|
||||||
2683
pnpm-lock.yaml
generated
2683
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,77 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
PROJECT="infra-cms"
|
|
||||||
LOCAL_SCHEMA_PATH="./packages/cms-infra/schema/snapshot.yaml"
|
|
||||||
REMOTE_HOST="root@infra.mintel.me"
|
|
||||||
REMOTE_DIR="/opt/infra/directus"
|
|
||||||
|
|
||||||
ENV=$1
|
|
||||||
|
|
||||||
if [ -z "$ENV" ]; then
|
|
||||||
echo "Usage: ./scripts/cms-apply.sh [local|infra]"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
case $ENV in
|
|
||||||
local)
|
|
||||||
PROJECT="infra-cms"
|
|
||||||
# Derive monorepo root
|
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
|
||||||
ROOT_DIR="$( dirname "$SCRIPT_DIR" )"
|
|
||||||
CMD_PREFIX="docker compose -f $ROOT_DIR/packages/cms-infra/docker-compose.yml"
|
|
||||||
|
|
||||||
LOCAL_CONTAINER=$($CMD_PREFIX ps -q $PROJECT)
|
|
||||||
if [ -z "$LOCAL_CONTAINER" ]; then
|
|
||||||
echo "❌ Local $PROJECT container not found. Is it running?"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🧹 Reconciling database metadata..."
|
|
||||||
./scripts/cms-reconcile.sh
|
|
||||||
|
|
||||||
echo "🚀 Applying schema to LOCAL $PROJECT..."
|
|
||||||
docker exec "$LOCAL_CONTAINER" npx directus schema apply -y /directus/schema/snapshot.yaml
|
|
||||||
;;
|
|
||||||
infra)
|
|
||||||
# 'infra' is the remote production server for at-mintel
|
|
||||||
PROJECT="directus" # Remote project name
|
|
||||||
|
|
||||||
echo "🔍 Detecting remote container..."
|
|
||||||
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "docker ps --filter label=com.docker.compose.project=$PROJECT --filter label=com.docker.compose.service=directus -q")
|
|
||||||
|
|
||||||
if [ -z "$REMOTE_CONTAINER" ]; then
|
|
||||||
# Fallback to older name if labels fail
|
|
||||||
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "docker ps -f name=directus-directus-1 -q")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$REMOTE_CONTAINER" ]; then
|
|
||||||
echo "❌ Remote container for $ENV not found."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📦 Syncing extensions to REMOTE $ENV..."
|
|
||||||
# Ensure remote directory exists
|
|
||||||
ssh "$REMOTE_HOST" "mkdir -p $REMOTE_DIR/extensions"
|
|
||||||
rsync -avz --delete ./packages/cms-infra/extensions/ "$REMOTE_HOST:$REMOTE_DIR/extensions/"
|
|
||||||
|
|
||||||
echo "📤 Injecting snapshot directly into container $REMOTE_CONTAINER..."
|
|
||||||
# Inject file via stdin to avoid needing a host-side mount or scp path matching
|
|
||||||
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_CONTAINER sh -c 'cat > /tmp/snapshot.yaml'" < "$LOCAL_SCHEMA_PATH"
|
|
||||||
|
|
||||||
echo "🚀 Applying schema to REMOTE $ENV..."
|
|
||||||
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER npx directus schema apply -y /tmp/snapshot.yaml"
|
|
||||||
|
|
||||||
echo "🔄 Restarting remote Directus to clear cache..."
|
|
||||||
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose restart directus"
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER rm /tmp/snapshot.yaml"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "❌ Invalid environment: $ENV. Supported: local, infra."
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo "✨ Schema apply complete!"
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
# Derive monorepo root
|
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
|
||||||
ROOT_DIR="$( dirname "$SCRIPT_DIR" )"
|
|
||||||
DB_PATH="$ROOT_DIR/packages/cms-infra/database/data.db"
|
|
||||||
|
|
||||||
if [ ! -f "$DB_PATH" ]; then
|
|
||||||
echo "❌ Database not found at $DB_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
reconcile_table() {
|
|
||||||
local TABLE=$1
|
|
||||||
echo "🔍 Reconciling table: $TABLE"
|
|
||||||
|
|
||||||
# 1. Get all columns from SQLite
|
|
||||||
COLUMNS=$(sqlite3 "$DB_PATH" "PRAGMA table_info($TABLE);" | cut -d'|' -f2)
|
|
||||||
|
|
||||||
for COL in $COLUMNS; do
|
|
||||||
# Skip system columns if needed, but usually it's safer to just check if they exist in Directus
|
|
||||||
|
|
||||||
# 2. Check if field exists in directus_fields
|
|
||||||
EXISTS=$(sqlite3 "$DB_PATH" "SELECT count(*) FROM directus_fields WHERE collection = '$TABLE' AND field = '$COL';")
|
|
||||||
|
|
||||||
if [ "$EXISTS" -eq 0 ]; then
|
|
||||||
echo "➕ Registering missing field: $TABLE.$COL"
|
|
||||||
|
|
||||||
# Determine a basic interface based on column name or type (very simplified)
|
|
||||||
INTERFACE="input"
|
|
||||||
case $COL in
|
|
||||||
*id) INTERFACE="numeric" ;;
|
|
||||||
*text) INTERFACE="input-multiline" ;;
|
|
||||||
company|person|user_created|user_updated|feedback_id) INTERFACE="select-dropdown-m2o" ;;
|
|
||||||
date_created|date_updated) INTERFACE="datetime" ;;
|
|
||||||
screenshot|logo) INTERFACE="file" ;;
|
|
||||||
status|type) INTERFACE="select-dropdown" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
sqlite3 "$DB_PATH" "INSERT INTO directus_fields (collection, field, interface) VALUES ('$TABLE', '$COL', '$INTERFACE');"
|
|
||||||
else
|
|
||||||
echo "✅ Field already registered: $TABLE.$COL"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
# Run for known problematic tables
|
|
||||||
reconcile_table "visual_feedback"
|
|
||||||
reconcile_table "visual_feedback_comments"
|
|
||||||
reconcile_table "people"
|
|
||||||
reconcile_table "leads"
|
|
||||||
reconcile_table "client_users"
|
|
||||||
reconcile_table "companies"
|
|
||||||
|
|
||||||
echo "✨ SQL Reconciliation complete!"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
PROJECT="infra-cms"
|
|
||||||
SCHEMA_PATH="./packages/cms-infra/schema/snapshot.yaml"
|
|
||||||
CMD_PREFIX="docker-compose -f packages/cms-infra/docker-compose.yml"
|
|
||||||
|
|
||||||
# Detect local container
|
|
||||||
LOCAL_CONTAINER=$($CMD_PREFIX ps -q $PROJECT)
|
|
||||||
|
|
||||||
if [ -z "$LOCAL_CONTAINER" ]; then
|
|
||||||
echo "❌ Local $PROJECT container not found. Is it running?"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📸 Creating schema snapshot for local $PROJECT..."
|
|
||||||
# Note: we save it to the mounted volume path inside the container
|
|
||||||
docker exec "$LOCAL_CONTAINER" npx directus schema snapshot -y /directus/schema/snapshot.yaml
|
|
||||||
|
|
||||||
echo "🛠️ Repairing snapshot for Postgres compatibility..."
|
|
||||||
python3 ./scripts/fix_snapshot_v3.py
|
|
||||||
|
|
||||||
echo "✅ Snapshot saved and repaired at $SCHEMA_PATH"
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
|
|
||||||
echo "🚀 Starting CMS infrastructure..."
|
|
||||||
|
|
||||||
# 1. Build extensions (pass all arguments to handle flags like --link)
|
|
||||||
"$SCRIPT_DIR/sync-extensions.sh" "$@"
|
|
||||||
|
|
||||||
# Filter out --link before passing to docker compose
|
|
||||||
DOCKER_ARGS=()
|
|
||||||
for arg in "$@"; do
|
|
||||||
if [ "$arg" != "--link" ]; then
|
|
||||||
DOCKER_ARGS+=("$arg")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# 2. Docker compose up with arguments
|
|
||||||
cd "$REPO_ROOT/packages/cms-infra"
|
|
||||||
docker compose up -d "${DOCKER_ARGS[@]}"
|
|
||||||
|
|
||||||
# 3. Apply core patch
|
|
||||||
"$SCRIPT_DIR/patch-cms.sh"
|
|
||||||
|
|
||||||
echo "✨ CMS is up and patched!"
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
path = '/Users/marcmintel/Projects/at-mintel/packages/cms-infra/schema/snapshot.yaml'
|
|
||||||
if not os.path.exists(path):
|
|
||||||
print(f"File not found: {path}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
with open(path, 'r') as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
|
|
||||||
new_lines = []
|
|
||||||
current_collection = None
|
|
||||||
current_field = None
|
|
||||||
in_schema = False
|
|
||||||
|
|
||||||
fix_fields = {'id', 'company', 'user_created', 'user_updated', 'screenshot', 'logo', 'feedback_id'}
|
|
||||||
uuid_fields = {'id', 'company', 'user_created', 'user_updated'}
|
|
||||||
|
|
||||||
# For multi-pass logic
|
|
||||||
snapshot_has_feedback_id = False
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
stripped = line.strip()
|
|
||||||
|
|
||||||
if stripped.startswith('- collection:'):
|
|
||||||
current_collection = stripped.split(':')[-1].strip()
|
|
||||||
in_schema = False
|
|
||||||
elif stripped.startswith('field:'):
|
|
||||||
current_field = stripped.split(':')[-1].strip()
|
|
||||||
if current_collection == 'visual_feedback_comments' and current_field == 'feedback_id':
|
|
||||||
snapshot_has_feedback_id = True
|
|
||||||
elif stripped == 'schema:':
|
|
||||||
in_schema = True
|
|
||||||
elif stripped == 'meta:' or stripped.startswith('- collection:') or (not line.startswith(' ') and line.strip() and not line.startswith('-')):
|
|
||||||
in_schema = False
|
|
||||||
|
|
||||||
# Top-level field type
|
|
||||||
if not in_schema and stripped.startswith('type:') and current_field in uuid_fields:
|
|
||||||
line = line.replace('type: string', 'type: uuid')
|
|
||||||
|
|
||||||
# Schema data type
|
|
||||||
if in_schema and current_field in fix_fields:
|
|
||||||
if 'data_type: char' in line or 'data_type: varchar' in line:
|
|
||||||
line = line.replace('data_type: char', 'data_type: uuid').replace('data_type: varchar', 'data_type: uuid')
|
|
||||||
if 'max_length:' in line:
|
|
||||||
line = ' max_length: null\n'
|
|
||||||
|
|
||||||
new_lines.append(line)
|
|
||||||
|
|
||||||
# Handle Missing feedback_id Injection
|
|
||||||
if not snapshot_has_feedback_id:
|
|
||||||
# We find systemFields and inject before it
|
|
||||||
injected = False
|
|
||||||
final_lines = []
|
|
||||||
feedback_id_block = """ - collection: visual_feedback_comments
|
|
||||||
field: feedback_id
|
|
||||||
type: integer
|
|
||||||
meta:
|
|
||||||
collection: visual_feedback_comments
|
|
||||||
field: feedback_id
|
|
||||||
interface: select-dropdown-m2o
|
|
||||||
required: true
|
|
||||||
sort: 4
|
|
||||||
width: full
|
|
||||||
schema:
|
|
||||||
name: feedback_id
|
|
||||||
table: visual_feedback_comments
|
|
||||||
data_type: integer
|
|
||||||
is_nullable: false
|
|
||||||
is_indexed: true
|
|
||||||
foreign_key_table: visual_feedback
|
|
||||||
foreign_key_column: id
|
|
||||||
"""
|
|
||||||
for line in new_lines:
|
|
||||||
if 'systemFields:' in line and not injected:
|
|
||||||
final_lines.append(feedback_id_block)
|
|
||||||
injected = True
|
|
||||||
final_lines.append(line)
|
|
||||||
new_lines = final_lines
|
|
||||||
|
|
||||||
# Second pass for primary key nullability
|
|
||||||
final_lines = []
|
|
||||||
for i in range(len(new_lines)):
|
|
||||||
line = new_lines[i]
|
|
||||||
if 'is_primary_key: true' in line:
|
|
||||||
# Search backwards and forwards
|
|
||||||
for j in range(max(0, i-10), min(len(new_lines), i+10)):
|
|
||||||
if 'is_nullable: true' in new_lines[j]:
|
|
||||||
new_lines[j] = new_lines[j].replace('is_nullable: true', 'is_nullable: false')
|
|
||||||
final_lines.append(line)
|
|
||||||
|
|
||||||
with open(path, 'w') as f:
|
|
||||||
f.writelines(new_lines)
|
|
||||||
|
|
||||||
print("SUCCESS: Full normalization and field injection complete.")
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
CONTAINERS=("cms-infra-infra-cms-1" "at-mintel-directus-1")
|
|
||||||
|
|
||||||
echo "🔧 Checking for Directus containers to patch..."
|
|
||||||
|
|
||||||
for CONTAINER in "${CONTAINERS[@]}"; do
|
|
||||||
if [ "$(docker ps -q -f name=^/${CONTAINER}$)" ]; then
|
|
||||||
echo "🔧 Applying core patches to: $CONTAINER..."
|
|
||||||
|
|
||||||
# Capture output to determine if restart is needed
|
|
||||||
OUTPUT=$(docker exec -i "$CONTAINER" node << 'EOF'
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const { execSync } = require("node:child_process");
|
|
||||||
let patched = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Patch @directus/extensions node.js (Entrypoints)
|
|
||||||
const findNodeCmd = "find /directus/node_modules -path \"*/@directus/extensions/dist/node.js\"";
|
|
||||||
const nodePaths = execSync(findNodeCmd).toString().trim().split("\n").filter(Boolean);
|
|
||||||
|
|
||||||
nodePaths.forEach(targetPath => {
|
|
||||||
let content = fs.readFileSync(targetPath, "utf8");
|
|
||||||
let modified = false;
|
|
||||||
|
|
||||||
const filterPatch = 'extension.host === "app" && (extension.entrypoint.app || extension.entrypoint)';
|
|
||||||
|
|
||||||
// Only replace if the OLD pattern exists
|
|
||||||
if (content.includes('extension.host === "app" && !!extension.entrypoint.app')) {
|
|
||||||
content = content.replace(/extension\.host === "app" && !!extension\.entrypoint\.app/g, filterPatch);
|
|
||||||
modified = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only replace if the OLD pattern exists for entrypoint
|
|
||||||
// We check if "extension.entrypoint.app" is present but NOT part of our patch
|
|
||||||
// This is a simple heuristic: if the patch string is NOT present, but the target IS.
|
|
||||||
if (!content.includes("(extension.entrypoint.app || extension.entrypoint)")) {
|
|
||||||
if (content.includes("extension.entrypoint.app")) {
|
|
||||||
content = content.replace(/extension\.entrypoint\.app/g, "(extension.entrypoint.app || extension.entrypoint)");
|
|
||||||
modified = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modified) {
|
|
||||||
fs.writeFileSync(targetPath, content);
|
|
||||||
console.log(`✅ Entrypoint patched.`);
|
|
||||||
patched = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Patch @directus/api manager.js (HTML Injection)
|
|
||||||
const findManagerCmd = "find /directus/node_modules -path \"*/@directus/api/dist/extensions/manager.js\"";
|
|
||||||
const managerPaths = execSync(findManagerCmd).toString().trim().split("\n").filter(Boolean);
|
|
||||||
|
|
||||||
managerPaths.forEach(targetPath => {
|
|
||||||
let content = fs.readFileSync(targetPath, "utf8");
|
|
||||||
|
|
||||||
const original = "head: wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),";
|
|
||||||
const injection = "head: '<script type=\"module\" src=\"/extensions/sources/index.js\"></script>\\n' + wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),";
|
|
||||||
|
|
||||||
if (content.includes(original) && !content.includes("/extensions/sources/index.js")) {
|
|
||||||
content = content.replace(original, injection);
|
|
||||||
fs.writeFileSync(targetPath, content);
|
|
||||||
console.log(`✅ Injection patched.`);
|
|
||||||
patched = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Patch @directus/api app.js (CSP for unsafe-inline)
|
|
||||||
const findAppCmd = "find /directus/node_modules -path \"*/@directus/api/dist/app.js\"";
|
|
||||||
const appPaths = execSync(findAppCmd).toString().trim().split("\n").filter(Boolean);
|
|
||||||
|
|
||||||
appPaths.forEach(targetPath => {
|
|
||||||
let content = fs.readFileSync(targetPath, "utf8");
|
|
||||||
let modified = false;
|
|
||||||
|
|
||||||
const original = "scriptSrc: [\"'self'\", \"'unsafe-eval'\"],";
|
|
||||||
const patchedStr = "scriptSrc: [\"'self'\", \"'unsafe-eval'\", \"'unsafe-inline'\"],";
|
|
||||||
|
|
||||||
if (content.includes(original)) {
|
|
||||||
content = content.replace(original, patchedStr);
|
|
||||||
modified = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modified) {
|
|
||||||
fs.writeFileSync(targetPath, content);
|
|
||||||
console.log(`✅ CSP patched in app.js.`);
|
|
||||||
patched = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (patched) process.exit(100); // Signal restart needed
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ Error applying patch:", error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
EXIT_CODE=$?
|
|
||||||
echo "$OUTPUT"
|
|
||||||
|
|
||||||
if [ $EXIT_CODE -eq 100 ]; then
|
|
||||||
echo "🔄 Patches applied. Restarting Directus container: $CONTAINER..."
|
|
||||||
docker restart "$CONTAINER"
|
|
||||||
else
|
|
||||||
echo "✅ Container $CONTAINER is already patched. No restart needed."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "ℹ️ Container $CONTAINER not found. Skipping."
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "✨ All patches check complete."
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
REMOTE_HOST="root@infra.mintel.me"
|
|
||||||
REMOTE_DIR="/opt/infra/directus"
|
|
||||||
|
|
||||||
# DB Details (matching docker-compose defaults)
|
|
||||||
DB_USER="directus"
|
|
||||||
DB_NAME="directus"
|
|
||||||
|
|
||||||
ACTION=$1
|
|
||||||
ENV=$2
|
|
||||||
|
|
||||||
# Help
|
|
||||||
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
|
|
||||||
echo "Usage: ./scripts/sync-directus.sh [push|pull] [infra|testing|staging|production]"
|
|
||||||
echo ""
|
|
||||||
echo "Commands:"
|
|
||||||
echo " push Sync LOCAL data -> REMOTE"
|
|
||||||
echo " pull Sync REMOTE data -> LOCAL"
|
|
||||||
echo ""
|
|
||||||
echo "Environments:"
|
|
||||||
echo " infra (infra.mintel.me)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Map Environment
|
|
||||||
case $ENV in
|
|
||||||
infra)
|
|
||||||
PROJECT_NAME="directus"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "❌ Invalid environment: $ENV. Only 'infra' is currently configured for monorepo sync."
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Detect local containers
|
|
||||||
echo "🔍 Detecting local database..."
|
|
||||||
LOCAL_DB_CONTAINER=$(docker compose ps -q at-mintel-directus-db)
|
|
||||||
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
|
||||||
echo "❌ Local directus-db container not found. Is it running? (npm run dev)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$ACTION" == "push" ]; then
|
|
||||||
echo "🚀 Pushing Local Data to $ENV..."
|
|
||||||
|
|
||||||
# 1. DB Dump
|
|
||||||
echo "📦 Dumping local database..."
|
|
||||||
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
|
|
||||||
|
|
||||||
# 2. Upload Dump
|
|
||||||
echo "📤 Uploading dump to remote server..."
|
|
||||||
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
|
|
||||||
|
|
||||||
# 3. Restore on Remote
|
|
||||||
echo "🔄 Restoring dump on $ENV..."
|
|
||||||
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-postgres")
|
|
||||||
|
|
||||||
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
|
||||||
echo "❌ Remote $ENV-db container not found!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wipe remote DB clean before restore to avoid constraint errors
|
|
||||||
echo "🧹 Wiping remote database schema..."
|
|
||||||
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'"
|
|
||||||
|
|
||||||
echo "⚡ Restoring database..."
|
|
||||||
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
|
|
||||||
|
|
||||||
# 4. Sync Uploads
|
|
||||||
echo "📁 Syncing uploads (Local -> $ENV)..."
|
|
||||||
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/uploads/"
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
rm dump.sql
|
|
||||||
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
|
||||||
|
|
||||||
# 5. Restart Directus to trigger migrations and refresh schema cache
|
|
||||||
echo "🔄 Restarting remote Directus to apply migrations..."
|
|
||||||
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
|
|
||||||
|
|
||||||
echo "✨ Push to $ENV complete!"
|
|
||||||
|
|
||||||
elif [ "$ACTION" == "pull" ]; then
|
|
||||||
echo "📥 Pulling $ENV Data to Local..."
|
|
||||||
|
|
||||||
# 1. DB Dump on Remote
|
|
||||||
echo "📦 Dumping remote database ($ENV)..."
|
|
||||||
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-postgres")
|
|
||||||
|
|
||||||
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
|
||||||
echo "❌ Remote $ENV-db container not found!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
|
|
||||||
|
|
||||||
# 2. Download Dump
|
|
||||||
echo "📥 Downloading dump..."
|
|
||||||
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
|
|
||||||
|
|
||||||
# Wipe local DB clean before restore to avoid constraint errors
|
|
||||||
echo "🧹 Wiping local database schema..."
|
|
||||||
docker exec "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
|
|
||||||
|
|
||||||
echo "⚡ Restoring database locally..."
|
|
||||||
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
|
|
||||||
|
|
||||||
# 4. Sync Uploads
|
|
||||||
echo "📁 Syncing uploads ($ENV -> Local)..."
|
|
||||||
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/uploads/" ./directus/uploads/
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
rm dump.sql
|
|
||||||
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
|
||||||
|
|
||||||
echo "✨ Pull to Local complete!"
|
|
||||||
else
|
|
||||||
echo "Invalid action: $ACTION. Use push or pull."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
EXTENSIONS_ROOT="$REPO_ROOT/packages"
|
|
||||||
|
|
||||||
# Strict local targets for bombproof isolation
|
|
||||||
TARGET_DIRS=(
|
|
||||||
"$REPO_ROOT/packages/cms-infra/extensions"
|
|
||||||
"$REPO_ROOT/directus/extensions"
|
|
||||||
)
|
|
||||||
|
|
||||||
# List of extension packages to sync
|
|
||||||
EXTENSION_PACKAGES=(
|
|
||||||
"acquisition"
|
|
||||||
"acquisition-manager"
|
|
||||||
"company-manager"
|
|
||||||
"customer-manager"
|
|
||||||
"feedback-commander"
|
|
||||||
"people-manager"
|
|
||||||
"unified-dashboard"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse flags
|
|
||||||
LINK_MODE=false
|
|
||||||
for arg in "$@"; do
|
|
||||||
if [ "$arg" == "--link" ]; then
|
|
||||||
LINK_MODE=true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "🚀 Starting isolated extension sync..."
|
|
||||||
|
|
||||||
# Ensure target directories exist
|
|
||||||
for TARGET in "${TARGET_DIRS[@]}"; do
|
|
||||||
mkdir -p "$TARGET"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Build the acquisition library if it exists
|
|
||||||
if [ -d "$REPO_ROOT/packages/acquisition" ]; then
|
|
||||||
echo "📦 Building acquisition..."
|
|
||||||
(cd "$REPO_ROOT/packages/acquisition" && pnpm build)
|
|
||||||
fi
|
|
||||||
|
|
||||||
for PKG in "${EXTENSION_PACKAGES[@]}"; do
|
|
||||||
PKG_PATH="$EXTENSIONS_ROOT/$PKG"
|
|
||||||
|
|
||||||
if [ -d "$PKG_PATH" ]; then
|
|
||||||
echo "📦 Processing $PKG..."
|
|
||||||
|
|
||||||
# 1. Build the extension
|
|
||||||
(cd "$PKG_PATH" && pnpm build)
|
|
||||||
|
|
||||||
EXT_NAME="$PKG"
|
|
||||||
echo "🚚 Syncing $EXT_NAME..."
|
|
||||||
|
|
||||||
# 3. Sync to each target directory
|
|
||||||
for TARGET_BASE in "${TARGET_DIRS[@]}"; do
|
|
||||||
# FLAT STRUCTURE: Directus 11.15.x local scanner is FLAT.
|
|
||||||
FINAL_TARGET="$TARGET_BASE/$EXT_NAME"
|
|
||||||
|
|
||||||
echo "🚚 Syncing $EXT_NAME to $FINAL_TARGET..."
|
|
||||||
|
|
||||||
# Clean target first to avoid ghost files
|
|
||||||
mkdir -p "$FINAL_TARGET"
|
|
||||||
rm -rf "${FINAL_TARGET:?}"/*
|
|
||||||
|
|
||||||
# Copy build artifacts
|
|
||||||
if [ -f "$PKG_PATH/dist/index.js" ]; then
|
|
||||||
cp "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
|
|
||||||
elif [ -f "$PKG_PATH/index.js" ]; then
|
|
||||||
cp "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "$PKG_PATH/package.json" ]; then
|
|
||||||
# We ALWAYS copy and patch package.json to avoid messing with source
|
|
||||||
cp "$PKG_PATH/package.json" "$FINAL_TARGET/"
|
|
||||||
# We force the registration path to index.js and ensure host/source are set
|
|
||||||
node -e "
|
|
||||||
const fs = require('fs');
|
|
||||||
const pkgPath = '$FINAL_TARGET/package.json';
|
|
||||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
||||||
if (!pkg['directus:extension']) pkg['directus:extension'] = {};
|
|
||||||
|
|
||||||
// Standard metadata for Directus 11.15.x (with core patch applied)
|
|
||||||
pkg['directus:extension'].path = 'index.js';
|
|
||||||
if (!pkg['directus:extension'].host) {
|
|
||||||
pkg['directus:extension'].host = pkg['directus:extension'].type === 'endpoint' ? 'api' : 'app';
|
|
||||||
}
|
|
||||||
if (!pkg['directus:extension'].source) {
|
|
||||||
pkg['directus:extension'].source = 'src/index.ts';
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
|
|
||||||
"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d "$PKG_PATH/dist" ]; then
|
|
||||||
if [ "$LINK_MODE" = true ]; then
|
|
||||||
REL_PATH=$(python3 -c "import os; print(os.path.relpath('$PKG_PATH/dist', '$FINAL_TARGET'))")
|
|
||||||
ln -sf "$REL_PATH" "$FINAL_TARGET/dist"
|
|
||||||
else
|
|
||||||
cp -r "$PKG_PATH/dist" "$FINAL_TARGET/"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "✅ $PKG synced."
|
|
||||||
else
|
|
||||||
echo "❌ Extension source not found: $PKG_PATH"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Cleanup: remove anything from extensions root that isn't in our whitelist
|
|
||||||
WHITELIST=("${EXTENSION_PACKAGES[@]}" "sources" "endpoints" "hooks" "layouts" "modules" "operations" "panels" "displays" "interfaces")
|
|
||||||
|
|
||||||
for TARGET_BASE in "${TARGET_DIRS[@]}"; do
|
|
||||||
echo "🧹 Cleaning up $TARGET_BASE..."
|
|
||||||
for ITEM in "$TARGET_BASE"/*; do
|
|
||||||
[ -e "$ITEM" ] || continue
|
|
||||||
BN=$(basename "$ITEM")
|
|
||||||
IS_ALLOWED=false
|
|
||||||
for W in "${WHITELIST[@]}"; do
|
|
||||||
if [[ "$BN" == "$W" ]]; then IS_ALLOWED=true; break; fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$IS_ALLOWED" = false ]; then
|
|
||||||
echo " 🗑️ Removing extra/legacy item: $BN"
|
|
||||||
rm -rf "$ITEM"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
# Container patching is now handled by scripts/patch-cms.sh
|
|
||||||
# which should be run AFTER the containers are up.
|
|
||||||
|
|
||||||
echo "✨ Sync complete! Extensions are in packages/cms-infra/extensions."
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
CONTAINER="cms-infra-infra-cms-1"
|
|
||||||
|
|
||||||
echo "🔍 Validating Directus Extension Stability..."
|
|
||||||
|
|
||||||
# 1. Verify Patches
|
|
||||||
echo "🛠️ Checking Core Patches..."
|
|
||||||
docker exec -i "$CONTAINER" node << 'EOF'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const { execSync } = require('node:child_process');
|
|
||||||
|
|
||||||
let failures = 0;
|
|
||||||
|
|
||||||
// Check Node.js patch
|
|
||||||
const findNode = 'find /directus/node_modules -path "*/@directus/extensions/dist/node.js"';
|
|
||||||
const nodePaths = execSync(findNode).toString().trim().split('\n').filter(Boolean);
|
|
||||||
nodePaths.forEach(p => {
|
|
||||||
const c = fs.readFileSync(p, 'utf8');
|
|
||||||
if (!c.includes('(extension.entrypoint.app || extension.entrypoint)')) {
|
|
||||||
console.error('❌ Missing node.js patch at ' + p);
|
|
||||||
failures++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check Manager.js patch
|
|
||||||
const findManager = 'find /directus/node_modules -path "*/@directus/api/dist/extensions/manager.js"';
|
|
||||||
const managerPaths = execSync(findManager).toString().trim().split('\n').filter(Boolean);
|
|
||||||
managerPaths.forEach(p => {
|
|
||||||
const c = fs.readFileSync(p, 'utf8');
|
|
||||||
if (!c.includes('/extensions/sources/index.js')) {
|
|
||||||
console.error('❌ Missing manager.js patch at ' + p);
|
|
||||||
failures++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (failures === 0) {
|
|
||||||
console.log('✅ Core patches are healthy.');
|
|
||||||
}
|
|
||||||
process.exit(failures > 0 ? 1 : 0);
|
|
||||||
EOF
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "⚠️ Core patches missing! Run 'bash scripts/patch-cms.sh' to fix."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Verify Module Bar
|
|
||||||
echo "📋 Checking Sidebar Configuration..."
|
|
||||||
docker exec -i "$CONTAINER" node << 'EOF'
|
|
||||||
const sqlite3 = require('/directus/node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3');
|
|
||||||
const db = new sqlite3.Database('/directus/database/data.db');
|
|
||||||
const managerIds = ["unified-dashboard", "acquisition-manager", "company-manager", "customer-manager", "feedback-commander", "people-manager"];
|
|
||||||
|
|
||||||
db.get('SELECT module_bar FROM directus_settings WHERE id = 1', (err, row) => {
|
|
||||||
if (err) { console.error('❌ DB Error:', err.message); process.exit(1); }
|
|
||||||
|
|
||||||
let mb = [];
|
|
||||||
try { mb = JSON.parse(row.module_bar || '[]'); } catch(e) { mb = []; }
|
|
||||||
|
|
||||||
const existingIds = mb.map(m => m.id);
|
|
||||||
const missing = managerIds.filter(id => !existingIds.includes(id));
|
|
||||||
const disabled = mb.filter(m => managerIds.includes(m.id) && m.enabled === false);
|
|
||||||
|
|
||||||
if (missing.length === 0 && disabled.length === 0) {
|
|
||||||
console.log('✅ Sidebar is healthy with all manager modules enabled.');
|
|
||||||
process.exit(0);
|
|
||||||
} else {
|
|
||||||
if (missing.length > 0) console.log('⚠️ Missing modules:', missing.join(', '));
|
|
||||||
if (disabled.length > 0) console.log('⚠️ Disabled modules:', disabled.map(m => m.id).join(', '));
|
|
||||||
|
|
||||||
console.log('🔧 Self-healing in progress...');
|
|
||||||
|
|
||||||
// Construct Golden State Module Bar
|
|
||||||
const goldenMB = [
|
|
||||||
{ type: 'module', id: 'content', enabled: true },
|
|
||||||
{ type: 'module', id: 'users', enabled: true },
|
|
||||||
{ type: 'module', id: 'files', enabled: true },
|
|
||||||
{ type: 'module', id: 'insights', enabled: true },
|
|
||||||
...managerIds.map(id => ({ type: 'module', id, enabled: true })),
|
|
||||||
{ type: 'module', id: 'settings', enabled: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
db.run('UPDATE directus_settings SET module_bar = ? WHERE id = 1', [JSON.stringify(goldenMB)], function(err) {
|
|
||||||
if (err) { console.error('❌ Repair failed:', err.message); process.exit(1); }
|
|
||||||
console.log('✨ Sidebar repaired successfully!');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
EOF
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
|
|
||||||
TARGET_DIRS=(
|
|
||||||
"$REPO_ROOT/packages/cms-infra/extensions"
|
|
||||||
"$REPO_ROOT/directus/extensions"
|
|
||||||
)
|
|
||||||
|
|
||||||
echo "🛡️ Directus Extension Validator"
|
|
||||||
echo "================================="
|
|
||||||
|
|
||||||
for TARGET in "${TARGET_DIRS[@]}"; do
|
|
||||||
echo ""
|
|
||||||
echo "📂 Checking: $TARGET"
|
|
||||||
|
|
||||||
if [ ! -d "$TARGET" ]; then
|
|
||||||
echo " ❌ Directory does not exist!"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
CATEGORIES=("endpoints" "hooks" "layouts" "modules" "operations" "panels" "displays" "interfaces")
|
|
||||||
FOUND_ANY=false
|
|
||||||
|
|
||||||
for CAT in "${CATEGORIES[@]}"; do
|
|
||||||
CAT_PATH="$TARGET/$CAT"
|
|
||||||
if [ -d "$CAT_PATH" ]; then
|
|
||||||
EXTS=$(ls "$CAT_PATH")
|
|
||||||
if [ -n "$EXTS" ]; then
|
|
||||||
FOUND_ANY=true
|
|
||||||
echo " 📦 $CAT:"
|
|
||||||
for EXT in $EXTS; do
|
|
||||||
EXT_PATH="$CAT_PATH/$EXT"
|
|
||||||
if [ -f "$EXT_PATH/package.json" ]; then
|
|
||||||
VERSION=$(node -e "console.log(require('$EXT_PATH/package.json').version)")
|
|
||||||
echo " ✅ $EXT (v$VERSION)"
|
|
||||||
else
|
|
||||||
echo " ⚠️ $EXT (MISSING package.json!)"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$FOUND_ANY" = false ]; then
|
|
||||||
echo " 📭 No extensions found in standard category folders."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for legacy files
|
|
||||||
LEGACY=$(find "$TARGET" -maxdepth 1 -not -path "$TARGET" -not -name ".*" -type d)
|
|
||||||
for L in $LEGACY; do
|
|
||||||
BN=$(basename "$L")
|
|
||||||
IS_CAT=false
|
|
||||||
for CAT in "${CATEGORIES[@]}"; do
|
|
||||||
if [ "$BN" == "$CAT" ]; then IS_CAT=true; break; fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$IS_CAT" = false ]; then
|
|
||||||
echo " 🚨 LEGACY/UNRESOLVED FOLDER FOUND: $BN (Will NOT be loaded by Directus)"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✨ Validation complete."
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# validate-sdk-imports.sh
|
|
||||||
# Validates that Directus extensions only use exports that exist in @directus/extensions-sdk.
|
|
||||||
# Prevents the "SyntaxError: doesn't provide an export named" runtime crash
|
|
||||||
# that silently breaks ALL extensions in the browser.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
|
|
||||||
# Valid exports from @directus/extensions-sdk in Directus 11.x
|
|
||||||
# If Directus is upgraded, update this list by running:
|
|
||||||
# curl -s http://cms.localhost/admin/assets/@directus_extensions-sdk.*.entry.js | grep -oE 'export\{[^}]+\}'
|
|
||||||
VALID_EXPORTS=(
|
|
||||||
"defineDisplay"
|
|
||||||
"defineEndpoint"
|
|
||||||
"defineHook"
|
|
||||||
"defineInterface"
|
|
||||||
"defineLayout"
|
|
||||||
"defineModule"
|
|
||||||
"defineOperationApi"
|
|
||||||
"defineOperationApp"
|
|
||||||
"definePanel"
|
|
||||||
"defineTheme"
|
|
||||||
"getFieldsFromTemplate"
|
|
||||||
"getRelationType"
|
|
||||||
"useApi"
|
|
||||||
"useCollection"
|
|
||||||
"useExtensions"
|
|
||||||
"useFilterFields"
|
|
||||||
"useItems"
|
|
||||||
"useLayout"
|
|
||||||
"useSdk"
|
|
||||||
"useStores"
|
|
||||||
"useSync"
|
|
||||||
)
|
|
||||||
|
|
||||||
ERRORS=0
|
|
||||||
|
|
||||||
echo "🔍 Validating @directus/extensions-sdk imports..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Search all .ts and .vue files in extension directories
|
|
||||||
SEARCH_DIRS=(
|
|
||||||
"$REPO_ROOT/packages/cms-infra/extensions"
|
|
||||||
"$REPO_ROOT/packages/unified-dashboard"
|
|
||||||
"$REPO_ROOT/packages/customer-manager"
|
|
||||||
"$REPO_ROOT/packages/company-manager"
|
|
||||||
"$REPO_ROOT/packages/people-manager"
|
|
||||||
"$REPO_ROOT/packages/acquisition-manager"
|
|
||||||
"$REPO_ROOT/packages/feedback-commander"
|
|
||||||
)
|
|
||||||
|
|
||||||
for DIR in "${SEARCH_DIRS[@]}"; do
|
|
||||||
[ -d "$DIR" ] || continue
|
|
||||||
|
|
||||||
# Find all imports from @directus/extensions-sdk
|
|
||||||
while IFS= read -r line; do
|
|
||||||
FILE=$(echo "$line" | cut -d: -f1)
|
|
||||||
LINENUM=$(echo "$line" | cut -d: -f2)
|
|
||||||
CONTENT=$(echo "$line" | cut -d: -f3-)
|
|
||||||
|
|
||||||
# Extract imported names from the import statement
|
|
||||||
IMPORTS=$(echo "$CONTENT" | grep -oE '\{[^}]+\}' | tr -d '{}' | tr ',' '\n' | sed 's/^ *//;s/ *$//' | sed 's/ as .*//')
|
|
||||||
|
|
||||||
for IMPORT in $IMPORTS; do
|
|
||||||
[ -z "$IMPORT" ] && continue
|
|
||||||
FOUND=false
|
|
||||||
for VALID in "${VALID_EXPORTS[@]}"; do
|
|
||||||
if [ "$IMPORT" = "$VALID" ]; then
|
|
||||||
FOUND=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$FOUND" = false ]; then
|
|
||||||
echo "❌ INVALID IMPORT: '$IMPORT' in $FILE:$LINENUM"
|
|
||||||
echo " '$IMPORT' is NOT exported by @directus/extensions-sdk in Directus 11.x"
|
|
||||||
echo " This WILL crash ALL extensions at runtime!"
|
|
||||||
echo ""
|
|
||||||
ERRORS=$((ERRORS + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
done < <(grep -rn "from ['\"]@directus/extensions-sdk['\"]" "$DIR" --include="*.ts" --include="*.vue" 2>/dev/null || true)
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$ERRORS" -gt 0 ]; then
|
|
||||||
echo "💥 Found $ERRORS invalid import(s)!"
|
|
||||||
echo ""
|
|
||||||
echo "Valid exports from @directus/extensions-sdk:"
|
|
||||||
printf " %s\n" "${VALID_EXPORTS[@]}"
|
|
||||||
echo ""
|
|
||||||
echo "Common fixes:"
|
|
||||||
echo " useRouter → import { useRouter } from 'vue-router'"
|
|
||||||
echo " useRoute → import { useRoute } from 'vue-router'"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "✅ All @directus/extensions-sdk imports are valid."
|
|
||||||
fi
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
HOST="http://cms.localhost"
|
|
||||||
EXTENSIONS=("customer-manager" "people-manager" "company-manager" "feedback-commander" "unified-dashboard")
|
|
||||||
|
|
||||||
echo "🔍 Verifying extensions at $HOST..."
|
|
||||||
|
|
||||||
# 1. Check Main Manifest
|
|
||||||
MANIFEST=$(curl -s "$HOST/extensions/sources/index.js")
|
|
||||||
|
|
||||||
if [ -z "$MANIFEST" ]; then
|
|
||||||
echo "❌ Error: Manifest returned empty response."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Manifest loaded (${#MANIFEST} bytes)."
|
|
||||||
|
|
||||||
# 2. Check for unexpected 404/500
|
|
||||||
if echo "$MANIFEST" | grep -q "<!DOCTYPE html>"; then
|
|
||||||
echo "❌ Error: Manifest returned HTML (likely 404 or error page) instead of JS."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. Verify each extension is in the bundle
|
|
||||||
FAILURE=0
|
|
||||||
for EXT in "${EXTENSIONS[@]}"; do
|
|
||||||
# Directus bundles strings usually, or imports them.
|
|
||||||
# We look for the ID or the unique module name from src (e.g. "Customer Manager")
|
|
||||||
# Or simply the path matching.
|
|
||||||
|
|
||||||
if echo "$MANIFEST" | grep -q "$EXT"; then
|
|
||||||
echo "✅ Found '$EXT' in manifest."
|
|
||||||
else
|
|
||||||
echo "❌ MISSING '$EXT' in manifest!"
|
|
||||||
FAILURE=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ $FAILURE -eq 1 ]; then
|
|
||||||
echo "🚨 VERIFICATION FAILED: One or more extensions are missing from the public bundle."
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "🎉 ALL EXTENSIONS VERIFIED."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
Reference in New Issue
Block a user