Compare commits

...

12 Commits

Author SHA1 Message Date
29a414f385 fix(qa): resolve lint errors and unused variables across packages
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 7m9s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m18s
Monorepo Pipeline / 🏗️ Build (push) Successful in 8m33s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 53s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 57s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 55s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m22s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 4m53s
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 5s
2026-02-14 15:34:54 +01:00
69764e42c6 fix(pipeline): improve prioritization to prevent redundant branch and tag runs
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🏗️ Build (push) Failing after 23m30s
Monorepo Pipeline / 🧪 Test (push) Failing after 24m32s
Monorepo Pipeline / 🧹 Lint (push) Failing after 24m34s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-14 14:00:08 +01:00
d69ade6268 chore: update lockfile and commit all pending release fixes
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 2m2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m14s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
2026-02-14 13:57:46 +01:00
ceaf3ae3ea chore: sync versions to 1.8.4
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 4s
Monorepo Pipeline / 🧹 Lint (push) Failing after 26s
Monorepo Pipeline / 🧪 Test (push) Failing after 27s
Monorepo Pipeline / 🏗️ Build (push) Failing after 25s
Monorepo Pipeline / 🚀 Release (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
2026-02-14 13:39:39 +01:00
169cb83f69 fix(pipeline): allow all tags and chore commits for releases
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m5s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m13s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m53s
Monorepo Pipeline / 🚀 Release (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
2026-02-14 13:39:26 +01:00
f831a7e67e chore(next-feedback): bump to 1.8.4 and export FeedbackOverlay from root
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 27s
Monorepo Pipeline / 🧪 Test (push) Successful in 57s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m53s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m23s
Monorepo Pipeline / 🚀 Release (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
2026-02-14 12:48:52 +01:00
cb4ffcaeda feat(next-feedback): convert FeedbackOverlay to controlled component
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 50s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m46s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m46s
Monorepo Pipeline / 🚀 Release (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
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 5s
2026-02-14 02:05:02 +01:00
9b1f3fb7e8 feat(next-feedback): add onActiveChange prop for controlled activation
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m1s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
2026-02-14 02:03:13 +01:00
f48d89c368 chore: comprehensive commit of all debugging, infrastructure, and extension fixes
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 6s
Monorepo Pipeline / 🧪 Test (push) Successful in 56s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m22s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m51s
Monorepo Pipeline / 🚀 Release (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
Summary of changes:
- Corrected Directus extensions to use 'vue-router' for 'useRouter' instead of '@directus/extensions-sdk' (Fixed runtime crash).
- Standardized extension folder structure and moved built extensions to the root 'directus/extensions' directory.
- Updated 'scripts/sync-extensions.sh' and 'scripts/validate-extensions.sh' for better extension management.
- Added 'scripts/validate-sdk-imports.sh' as a safeguard against future invalid SDK imports.
- Integrated import validation into the '.husky/pre-push' hook.
- Standardized Docker restart policies and network configurations in 'cms-infra/docker-compose.yml'.
- Updated tracked 'data.db' with the correct 'module_bar' settings to ensure extension visibility.
- Cleaned up legacy files and consolidated extension package source code.

This commit captures the full state of the repository after resolving the 'missing extensions' issue.
2026-02-14 01:44:18 +01:00
ad40e71757 fix: replace invalid useRouter import from @directus/extensions-sdk with vue-router
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 26s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
The Directus 11.x SDK does not export useRouter. Importing it caused a
SyntaxError that crashed the entire extensions bundle, preventing ALL
modules from appearing in the Data Studio sidebar.

Changes:
- Replace useRouter import from @directus/extensions-sdk → vue-router
- Add scripts/validate-sdk-imports.sh to catch invalid SDK imports
- Integrate SDK import validation into pre-push hook
- Add EXTENSIONS_AUTO_RELOAD to docker-compose.yml
- Remove debug NODE_ENV=development
2026-02-14 01:43:10 +01:00
911ceffdc5 fix(pipeline): serialize image builds to prevent act cache collisions
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m0s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m41s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m19s
Monorepo Pipeline / 🚀 Release (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
2026-02-13 15:01:34 +01:00
23358fc708 fix: temporary trigger test
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 8m49s
Monorepo Pipeline / 🧹 Lint (push) Successful in 9m13s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Failing after 51s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 1m34s
Monorepo Pipeline / 🏗️ Build (push) Successful in 6m58s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 20s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 18s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m58s
2026-02-13 14:38:01 +01:00
91 changed files with 245065 additions and 794 deletions

6
.env
View File

@@ -1,5 +1,5 @@
# Project # Project
IMAGE_TAG=v1.8.2 IMAGE_TAG=1.8.4
PROJECT_NAME=at-mintel PROJECT_NAME=at-mintel
PROJECT_COLOR=#82ed20 PROJECT_COLOR=#82ed20
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582 GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
@@ -10,13 +10,13 @@ AUTH_COOKIE_NAME=mintel_gatekeeper_session
# Host Config (Local) # Host Config (Local)
TRAEFIK_HOST=at-mintel.localhost TRAEFIK_HOST=at-mintel.localhost
DIRECTUS_HOST=cms.localhost DIRECTUS_HOST=cms-legacy.localhost
# Next.js # Next.js
NEXT_PUBLIC_BASE_URL=http://at-mintel.localhost NEXT_PUBLIC_BASE_URL=http://at-mintel.localhost
# Directus # Directus
DIRECTUS_URL=http://cms.localhost DIRECTUS_URL=http://cms-legacy.localhost
DIRECTUS_KEY=F9IIfahEjPq6NZhKyRLw516D8GotuFj79EGK7pGfIWg= DIRECTUS_KEY=F9IIfahEjPq6NZhKyRLw516D8GotuFj79EGK7pGfIWg=
DIRECTUS_SECRET=OZfxMu8lBxzaEnFGRKreNBoJpRiRu58U+HsVg2yWk4o= DIRECTUS_SECRET=OZfxMu8lBxzaEnFGRKreNBoJpRiRu58U+HsVg2yWk4o=
CORS_ENABLED=true CORS_ENABLED=true

View File

@@ -1,5 +1,5 @@
# Project # Project
IMAGE_TAG=v1.8.2 IMAGE_TAG=1.8.4
PROJECT_NAME=sample-website PROJECT_NAME=sample-website
PROJECT_COLOR=#82ed20 PROJECT_COLOR=#82ed20

View File

@@ -5,7 +5,7 @@ on:
branches: branches:
- '**' - '**'
tags: tags:
- 'v*' - '*'
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -26,16 +26,17 @@ jobs:
REF: ${{ github.ref }} REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }} REF_NAME: ${{ github.ref_name }}
EVENT: ${{ github.event_name }} EVENT: ${{ github.event_name }}
SHA: ${{ github.sha }}
run: | run: |
echo "🔎 Debug: Event=$EVENT, Ref=$REF, RefName=$REF_NAME, RunId=$RUN_ID" echo "🔎 Debug: Event=$EVENT, Ref=$REF, RefName=$REF_NAME, RunId=$RUN_ID"
# Fetch recent runs for the repository
RUNS=$(curl -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs?limit=30")
case "$REF" in case "$REF" in
refs/tags/v*) refs/tags/*)
echo "🚀 Release detected ($REF_NAME). Cancelling non-tag runs..." echo "🚀 Release detected ($REF_NAME). Cancelling non-tag runs..."
# Fetch all runs
RUNS=$(curl -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs")
# Identify runs to cancel: in_progress/queued, NOT this run, and NOT a tag run # Identify runs to cancel: in_progress/queued, NOT this run, and NOT a tag run
echo "$RUNS" | jq -c '.workflow_runs[] | select(.status == "in_progress" or .status == "queued") | select(.id | tostring != "'$RUN_ID'")' | while read -r run; do echo "$RUNS" | jq -c '.workflow_runs[] | select(.status == "in_progress" or .status == "queued") | select(.id | tostring != "'$RUN_ID'")' | while read -r run; do
ID=$(echo "$run" | jq -r '.id') ID=$(echo "$run" | jq -r '.id')
@@ -43,7 +44,7 @@ jobs:
TITLE=$(echo "$run" | jq -r '.display_title') TITLE=$(echo "$run" | jq -r '.display_title')
case "$RUN_REF" in case "$RUN_REF" in
refs/tags/v*) refs/tags/*)
echo "⏭️ Skipping parallel release run $ID ($TITLE) on $RUN_REF" echo "⏭️ Skipping parallel release run $ID ($TITLE) on $RUN_REF"
;; ;;
*) *)
@@ -54,7 +55,17 @@ jobs:
done done
;; ;;
*) *)
echo " Regular push. No prioritization needed." echo " Regular push. Checking for parallel release tag for SHA $SHA..."
# Check if there's a tag run for the SAME commit
TAG_RUN_ID=$(echo "$RUNS" | jq -r '.workflow_runs[] | select(.ref | startswith("refs/tags/")) | select(.head_sha == "'$SHA'") | .id' | head -n 1)
if [[ -n "$TAG_RUN_ID" && "$TAG_RUN_ID" != "null" ]]; then
echo "🚀 Found parallel tag run $TAG_RUN_ID for commit $SHA. Cancelling this branch run ($RUN_ID)..."
curl -X POST -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs/$RUN_ID/cancel"
exit 0
fi
echo "✅ No parallel tag run found. Proceeding."
;; ;;
esac esac
@@ -130,7 +141,7 @@ jobs:
release: release:
name: 🚀 Release name: 🚀 Release
needs: [lint, test, build] needs: [lint, test, build]
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/')
runs-on: docker runs-on: docker
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -160,12 +171,13 @@ jobs:
build-images: build-images:
name: 🐳 Build ${{ matrix.name }} name: 🐳 Build ${{ matrix.name }}
needs: [lint, test, build] needs: [lint, test, build]
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/')
runs-on: docker runs-on: docker
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 1
matrix: matrix:
include: include:
- image: nextjs - image: nextjs

View File

@@ -1,7 +1,14 @@
# Validate Directus SDK imports before push
# This prevents runtime crashes caused by importing non-existent exports
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
if [ -f "$SCRIPT_DIR/scripts/validate-sdk-imports.sh" ]; then
"$SCRIPT_DIR/scripts/validate-sdk-imports.sh" || exit 1
fi
# Check if we are pushing a tag # Check if we are pushing a tag
while read local_ref local_sha remote_ref remote_sha while read local_ref local_sha remote_ref remote_sha
do do
if [[ "$remote_ref" == refs/tags/v* ]]; then if [[ "$remote_ref" == refs/tags/* ]]; then
TAG=${remote_ref#refs/tags/} TAG=${remote_ref#refs/tags/}
echo "🏷️ Tag detected: $TAG, ensuring versions are synced..." echo "🏷️ Tag detected: $TAG, ensuring versions are synced..."

View File

@@ -80,3 +80,4 @@ Client websites scaffolded via the CLI use a **tag-based deployment** strategy:
- **Git Tag `v*.*.*`**: Deploys to the `production` environment. - **Git Tag `v*.*.*`**: Deploys to the `production` environment.
See the [`@mintel/infra`](packages/infra/README.md) package for detailed template documentation. See the [`@mintel/infra`](packages/infra/README.md) package for detailed template documentation.

View File

@@ -1,6 +1,6 @@
{ {
"name": "sample-website", "name": "sample-website",
"version": "1.8.2", "version": "1.8.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,28 @@
{
"name": "acquisition-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "app",
"name": "acquisition manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
{
"name": "acquisition",
"version": "1.8.2",
"type": "module",
"directus:extension": {
"type": "endpoint",
"path": "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"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,28 @@
{
"name": "company-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "app",
"name": "company manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,28 @@
{
"name": "customer-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "app",
"name": "customer manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
{
"name": "feedback-commander",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "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"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,28 @@
{
"name": "people-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "app",
"name": "people manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
{
"name": "unified-dashboard",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "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"
}
}

View File

@@ -1,19 +0,0 @@
version: 1
directus: 11.15.1
vendor: postgres
collections: []
fields: []
systemFields:
- collection: directus_activity
field: timestamp
schema:
is_indexed: true
- collection: directus_revisions
field: activity
schema:
is_indexed: true
- collection: directus_revisions
field: parent
schema:
is_indexed: true
relations: []

View File

@@ -1 +0,0 @@
S9WsV

View File

@@ -15,7 +15,7 @@
"cms:schema:snapshot": "./scripts/cms-snapshot.sh", "cms:schema:snapshot": "./scripts/cms-snapshot.sh",
"cms:schema:apply": "./scripts/cms-apply.sh local", "cms:schema:apply": "./scripts/cms-apply.sh local",
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra", "cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
"cms:up": "./scripts/sync-extensions.sh && cd packages/cms-infra && npm run up -- --force-recreate", "cms:up": "cd packages/cms-infra && npm run up -- --force-recreate",
"cms:down": "cd packages/cms-infra && npm run down", "cms:down": "cd packages/cms-infra && npm run down",
"cms:logs": "cd packages/cms-infra && npm run logs", "cms:logs": "cd packages/cms-infra && npm run logs",
"dev:infra": "docker-compose up -d directus directus-db", "dev:infra": "docker-compose up -d directus directus-db",
@@ -29,6 +29,7 @@
"@commitlint/config-conventional": "^20.4.0", "@commitlint/config-conventional": "^20.4.0",
"@mintel/eslint-config": "workspace:*", "@mintel/eslint-config": "workspace:*",
"@mintel/husky-config": "workspace:*", "@mintel/husky-config": "workspace:*",
"@next/eslint-plugin-next": "16.1.6",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^20.17.16", "@types/node": "^20.17.16",
@@ -36,7 +37,6 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"@next/eslint-plugin-next": "16.1.6",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"happy-dom": "^20.4.0", "happy-dom": "^20.4.0",
@@ -50,12 +50,13 @@
"vitest": "^4.0.18" "vitest": "^4.0.18"
}, },
"dependencies": { "dependencies": {
"globals": "^17.3.0",
"import-in-the-middle": "^3.0.0", "import-in-the-middle": "^3.0.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"require-in-the-middle": "^8.0.1" "require-in-the-middle": "^8.0.1"
}, },
"version": "1.8.2", "version": "1.8.4",
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"next": "16.1.6", "next": "16.1.6",

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
{ {
"name": "acquisition-manager", "name": "acquisition-manager",
"description": "Custom High-Fidelity Acquisition Management for Directus", "description": "Custom High-Fidelity Management for Directus",
"icon": "account_balance_wallet", "icon": "extension",
"version": "1.8.2", "version": "1.8.4",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"directus", "directus",
@@ -11,17 +11,18 @@
], ],
"directus:extension": { "directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "path": "dist/index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "*", "host": "app",
"name": "Acquisition Manager" "name": "acquisition manager"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

View File

@@ -1,8 +1,16 @@
<template> <template>
<private-view title="Acquisition Manager"> <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> <template #navigation>
<v-list nav> <v-list nav>
<v-list-item @click="showAddLead = true" clickable> <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-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
<v-list-item-content> <v-list-item-content>
<v-text-overflow text="Neuen Lead anlegen" /> <v-text-overflow text="Neuen Lead anlegen" />
@@ -15,7 +23,7 @@
v-for="lead in leads" v-for="lead in leads"
:key="lead.id" :key="lead.id"
:active="selectedLeadId === lead.id" :active="selectedLeadId === lead.id"
class="lead-item" class="nav-item"
clickable clickable
@click="selectLead(lead.id)" @click="selectLead(lead.id)"
> >
@@ -29,166 +37,136 @@
</v-list> </v-list>
</template> </template>
<template #title-outer:after> <template #subtitle>
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible> <template v-if="selectedLead">
{{ notice.message }} <v-icon name="language" x-small />
</v-notice> <a :href="selectedLead.website_url" target="_blank" class="url-link">
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }}
</a>
&middot; Status: {{ selectedLead.status.toUpperCase() }}
</template>
</template> </template>
<div class="content-wrapper"> <template #actions>
<v-notice type="success" style="margin-bottom: 16px;"> <v-button
DEBUG: Module Version 1.1.0 - Native Build - {{ new Date().toISOString() }} v-if="selectedLead?.status === 'new'"
</v-notice> secondary
:loading="loadingAudit"
@click="runAudit"
>
<v-icon name="settings_suggest" left />
Audit starten
</v-button>
<div v-if="!selectedLead" class="empty-state"> <template v-if="selectedLead?.status === 'audit_ready'">
<v-info title="Lead auswählen" icon="auto_awesome" center> <v-button secondary :loading="loadingEmail" @click="sendAuditEmail">
Wähle einen Lead in der Navigation aus oder <v-icon name="mail" left />
<v-button x-small @click="showAddLead = true">registriere einen neuen Lead</v-button>. Audit E-Mail
</v-info> </v-button>
</div> <v-button :loading="loadingPdf" @click="generatePdf">
<v-icon name="picture_as_pdf" left />
PDF Erstellen
</v-button>
</template>
<template v-else> <v-button v-if="selectedLead?.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
<header class="header"> <v-icon name="open_in_new" />
<div class="header-left"> </v-button>
<h1 class="title">{{ getCompanyName(selectedLead) }}</h1>
<p class="subtitle"> <v-button
<v-icon name="language" x-small /> v-if="selectedLead?.audit_pdf_path"
<a :href="selectedLead.website_url" target="_blank" class="url-link"> primary
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }} :loading="loadingEmail"
</a> @click="sendEstimateEmail"
&middot; Status: {{ selectedLead.status.toUpperCase() }} >
</p> <v-icon name="send" left />
</div> Angebot senden
<div class="header-right"> </v-button>
<v-button </template>
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'"> <template #empty-state>
<v-button secondary :loading="loadingEmail" @click="sendAuditEmail"> Wähle einen Lead in der Navigation aus oder
<v-icon name="mail" left /> <v-button x-small @click="openCreateDrawer">registriere einen neuen Lead</v-button>.
Audit E-Mail </template>
</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"> <div v-if="selectedLead" class="sections">
<v-icon name="open_in_new" /> <div class="main-info">
</v-button> <div class="form-grid">
<div class="field">
<v-button <span class="label">Kontaktperson</span>
v-if="selectedLead.audit_pdf_path" <div v-if="selectedLead.contact_person" class="value person-link" @click="goToPerson(selectedLead.contact_person)">
primary {{ getPersonName(selectedLead.contact_person) }}
:loading="loadingEmail"
@click="sendEstimateEmail"
>
<v-icon name="send" left />
Angebot senden
</v-button>
</div>
</header>
<div 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">
<span class="label">E-Mail (Legacy)</span>
<div class="value">{{ selectedLead.contact_email || '—' }}</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>
<div v-else class="value text-subdued">Keine Person verknüpft</div>
</div> </div>
<div class="field full">
<v-divider /> <span class="label">Briefing / Fokus</span>
<div class="value text-block">{{ selectedLead.briefing || 'Kein Briefing hinterlegt.' }}</div>
<div v-if="selectedLead.ai_state" class="ai-observations">
<h3 class="section-title">AI Observations & Estimation</h3>
<div class="metrics">
<v-info label="Projekt-Modus" :value="selectedLead.ai_state.projectType || 'Unbekannt'" />
<v-info label="Seitenanzahl" :value="selectedLead.ai_state.sitemap?.length || '0'" />
</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>
</div> </div>
</template> </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> </div>
<!-- Drawer: New Lead --> <!-- Drawer: New Lead -->
<v-drawer <v-drawer
v-model="showAddLead" v-model="drawerActive"
title="Neuen Lead registrieren" title="Neuen Lead registrieren"
icon="person_add" icon="person_add"
@cancel="showAddLead = false" @cancel="drawerActive = false"
> >
<div class="drawer-content"> <div class="drawer-content">
<div class="form-section"> <div class="form-section">
<div class="field"> <div class="field">
<span class="label">Organisation / Firma (Zentral)</span> <span class="label">Organisation / Firma (Zentral)</span>
<v-select <MintelSelect
v-model="newLead.company" v-model="newLead.company"
:items="companyOptions" :items="companyOptions"
placeholder="Bestehende Firma auswählen..." placeholder="Bestehende Firma auswählen..."
allow-add
@add="openQuickAdd('company')"
/> />
</div> </div>
<div class="field">
<span class="label">Organisation / Firma (Legacy / Neu)</span>
<v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus />
</div>
<div class="field"> <div class="field">
<span class="label">Website URL</span> <span class="label">Website URL</span>
<v-input v-model="newLead.website_url" placeholder="https://..." /> <v-input v-model="newLead.website_url" placeholder="https://..." />
</div> </div>
<div class="field">
<span class="label">Ansprechpartner</span>
<v-input v-model="newLead.contact_name" placeholder="Vorname Nachname" />
</div>
<div class="field">
<span class="label">E-Mail Adresse</span>
<v-input v-model="newLead.contact_email" placeholder="email@beispiel.de" type="email" />
</div>
<div class="field"> <div class="field">
<span class="label">Briefing / Fokus</span> <span class="label">Briefing / Fokus</span>
<v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." /> <v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." />
</div> </div>
<div class="field"> <div class="field">
<span class="label">Kontaktperson (Optional)</span> <span class="label">Kontaktperson (Optional)</span>
<v-select <MintelSelect
v-model="newLead.contact_person" v-model="newLead.contact_person"
:items="peopleOptions" :items="peopleOptions"
placeholder="Person auswählen..." placeholder="Person auswählen..."
allow-add
@add="openQuickAdd('person')"
/> />
</div> </div>
</div> </div>
@@ -198,12 +176,13 @@
</div> </div>
</div> </div>
</v-drawer> </v-drawer>
</private-view> </MintelManagerLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
import { MintelManagerLayout, MintelSelect, MintelStatCard } from '@mintel/directus-extension-toolkit';
const api = useApi(); const api = useApi();
const leads = ref<any[]>([]); const leads = ref<any[]>([]);
@@ -211,16 +190,13 @@ const selectedLeadId = ref<string | null>(null);
const loadingAudit = ref(false); const loadingAudit = ref(false);
const loadingPdf = ref(false); const loadingPdf = ref(false);
const loadingEmail = ref(false); const loadingEmail = ref(false);
const showAddLead = ref(false); const drawerActive = ref(false);
const savingLead = ref(false); const savingLead = ref(false);
const notice = ref<{ type: string; message: string } | null>(null); const notice = ref<{ type: string; message: string } | null>(null);
const newLead = ref({ const newLead = ref({
company_name: '', // Legacy
company: null, company: null,
website_url: '', website_url: '',
contact_name: '', // Legacy
contact_email: '', // Legacy
contact_person: null, contact_person: null,
briefing: '', briefing: '',
status: 'new' status: 'new'
@@ -244,10 +220,11 @@ const peopleOptions = computed(() =>
); );
function getCompanyName(lead: any) { function getCompanyName(lead: any) {
if (!lead) return '';
if (lead.company) { if (lead.company) {
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || lead.company_name); return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || 'Unbekannte Firma');
} }
return lead.company_name; return 'Unbekannte Organisation';
} }
function getPersonName(id: string | any) { function getPersonName(id: string | any) {
@@ -258,31 +235,32 @@ function getPersonName(id: string | any) {
} }
function goToPerson(id: string) { function goToPerson(id: string) {
// Logic to navigate to people manager or open details
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` }; notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
} }
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value)); const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
onMounted(fetchData);
async function fetchData() { async function fetchData() {
const [leadsResp, peopleResp, companiesResp] = await Promise.all([ try {
api.get('/items/leads', { const [leadsResp, peopleResp, companiesResp] = await Promise.all([
params: { api.get('/items/leads', {
sort: '-date_created', params: {
fields: '*.*' sort: '-date_created',
} fields: '*.*'
}), }
api.get('/items/people', { params: { sort: 'last_name' } }), }),
api.get('/items/companies', { params: { sort: 'name' } }) api.get('/items/people', { params: { sort: 'last_name' } }),
]); api.get('/items/companies', { params: { sort: 'name' } })
leads.value = leadsResp.data.data; ]);
people.value = peopleResp.data.data; leads.value = leadsResp.data.data;
companies.value = companiesResp.data.data; people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
if (!selectedLeadId.value && leads.value.length > 0) {
selectedLeadId.value = leads.value[0].id; if (!selectedLeadId.value && leads.value.length > 0) {
selectedLeadId.value = leads.value[0].id;
}
} catch (e: any) {
console.error('Fetch error:', e);
} }
} }
@@ -294,6 +272,17 @@ function selectLead(id: string) {
selectedLeadId.value = id; selectedLeadId.value = id;
} }
function openCreateDrawer() {
newLead.value = {
company: null,
website_url: '',
contact_person: null,
briefing: '',
status: 'new'
};
drawerActive.value = true;
}
async function runAudit() { async function runAudit() {
if (!selectedLeadId.value) return; if (!selectedLeadId.value) return;
loadingAudit.value = true; loadingAudit.value = true;
@@ -356,8 +345,8 @@ function openPdf() {
} }
async function saveLead() { async function saveLead() {
if (!newLead.value.company_name && !newLead.value.company) { if (!newLead.value.company) {
notice.value = { type: 'danger', message: 'Firma oder Firmenname erforderlich.' }; notice.value = { type: 'danger', message: 'Organisation erforderlich.' };
return; return;
} }
savingLead.value = true; savingLead.value = true;
@@ -368,19 +357,9 @@ async function saveLead() {
}; };
await api.post('/items/leads', payload); await api.post('/items/leads', payload);
notice.value = { type: 'success', message: 'Lead erfolgreich registriert!' }; notice.value = { type: 'success', message: 'Lead erfolgreich registriert!' };
showAddLead.value = false; drawerActive.value = false;
await fetchLeads(); await fetchLeads();
selectedLeadId.value = payload.id; selectedLeadId.value = payload.id;
newLead.value = {
company_name: '',
company: null,
website_url: '',
contact_name: '',
contact_email: '',
contact_person: null,
briefing: '',
status: 'new'
};
} catch (e: any) { } catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` }; notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
} finally { } finally {
@@ -388,6 +367,10 @@ async function saveLead() {
} }
} }
function openQuickAdd(type: string) {
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` };
}
function getStatusIcon(status: string) { function getStatusIcon(status: string) {
switch(status) { switch(status) {
case 'new': return 'fiber_new'; case 'new': return 'fiber_new';
@@ -407,18 +390,13 @@ function getStatusColor(status: string) {
default: return 'var(--theme--foreground-subdued)'; default: return 'var(--theme--foreground-subdued)';
} }
} }
onMounted(fetchData);
</script> </script>
<style scoped> <style scoped>
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; overflow-y: auto; }
.lead-item { cursor: pointer; }
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
.header-right { display: flex; gap: 12px; }
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; color: var(--theme--foreground); }
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; display: flex; align-items: center; gap: 8px; }
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; } .url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
.url-link:hover { border-bottom-color: currentColor; } .url-link:hover { border-bottom-color: currentColor; }
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
.sections { display: flex; flex-direction: column; gap: 32px; } .sections { display: flex; flex-direction: column; gap: 32px; }
@@ -431,7 +409,7 @@ function getStatusColor(status: string) {
.ai-observations { display: flex; flex-direction: column; gap: 16px; } .ai-observations { display: flex; flex-direction: column; gap: 16px; }
.section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; } .section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; }
.metrics { display: flex; gap: 32px; margin-bottom: 16px; } .metrics { display: flex; gap: 24px; margin-bottom: 16px; }
.observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; } .observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; }
.page-title { font-weight: 600; } .page-title { font-weight: 600; }
@@ -440,6 +418,4 @@ function getStatusColor(status: string) {
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; } .drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
.form-section { display: flex; flex-direction: column; gap: 20px; } .form-section { display: flex; flex-direction: column; gap: 20px; }
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; } .drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
:deep(.v-list-item) { cursor: pointer !important; }
</style> </style>

View File

@@ -1,6 +1,6 @@
{ {
"name": "acquisition", "name": "acquisition",
"version": "1.8.2", "version": "1.8.4",
"type": "module", "type": "module",
"directus:extension": { "directus:extension": {
"type": "endpoint", "type": "endpoint",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/cli", "name": "@mintel/cli",
"version": "1.8.2", "version": "1.8.4",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/cloner", "name": "@mintel/cloner",
"version": "1.8.2", "version": "1.8.4",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",

View File

@@ -0,0 +1,11 @@
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

BIN
packages/cms-infra/database/data.db Executable file → Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
services: services:
infra-cms: infra-cms:
image: directus/directus:11 image: directus/directus:11.15.2
ports: ports:
- "8059:8055" - "8059:8055"
networks: networks:
@@ -14,6 +14,7 @@ services:
DB_CLIENT: "sqlite3" DB_CLIENT: "sqlite3"
DB_FILENAME: "/directus/database/data.db" DB_FILENAME: "/directus/database/data.db"
WEBSOCKETS_ENABLED: "true" WEBSOCKETS_ENABLED: "true"
PUBLIC_URL: "http://cms.localhost"
EMAIL_TRANSPORT: "smtp" EMAIL_TRANSPORT: "smtp"
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org" EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
EMAIL_SMTP_PORT: "587" EMAIL_SMTP_PORT: "587"
@@ -21,19 +22,29 @@ services:
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6" EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
EMAIL_SMTP_SECURE: "false" EMAIL_SMTP_SECURE: "false"
EMAIL_FROM: "postmaster@mg.mintel.me" EMAIL_FROM: "postmaster@mg.mintel.me"
LOG_LEVEL: "debug"
SERVE_APP: "true"
EXTENSIONS_AUTO_RELOAD: "true"
volumes: volumes:
- ./database:/directus/database - ./database:/directus/database
- ./uploads:/directus/uploads - ./uploads:/directus/uploads
- ./schema:/directus/schema - ./schema:/directus/schema
- ./extensions:/directus/extensions - ./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: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.infra-cms.rule=Host(`cms.localhost`)" - "traefik.http.routers.at-mintel-infra-cms.rule=Host(`cms.localhost`)"
- "traefik.http.services.infra-cms.loadbalancer.server.port=8055" - "traefik.http.services.at-mintel-infra-cms.loadbalancer.server.port=8055"
- "traefik.http.services.at-mintel-infra-cms.loadbalancer.healthcheck.path=/server/health"
- "traefik.docker.network=infra" - "traefik.docker.network=infra"
networks: networks:
default: default:
name: mintel-infra-cms-internal name: at-mintel-cms-network
infra: infra:
external: true external: true

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +1,28 @@
{ {
"name": "acquisition-manager", "name": "acquisition-manager",
"description": "Custom High-Fidelity Acquisition Management for Directus", "description": "Custom High-Fidelity Management for Directus",
"icon": "account_balance_wallet", "icon": "extension",
"version": "1.7.12", "version": "1.8.2",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"directus", "directus",
"directus-extension", "directus-extension",
"directus-extension-module" "directus-extension-module"
], ],
"files": [
"dist"
],
"directus:extension": { "directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "path": "index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "*", "host": "app",
"name": "Acquisition Manager" "name": "acquisition manager"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
{
"name": "acquisition",
"version": "1.8.2",
"type": "module",
"directus:extension": {
"type": "endpoint",
"path": "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"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,28 @@
{
"name": "company-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "app",
"name": "company manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +1,28 @@
{ {
"name": "customer-manager", "name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus", "description": "Custom High-Fidelity Management for Directus",
"icon": "supervisor_account", "icon": "extension",
"version": "1.7.12", "version": "1.8.2",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"directus", "directus",
"directus-extension", "directus-extension",
"directus-extension-module" "directus-extension-module"
], ],
"files": [
"dist"
],
"directus:extension": { "directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "path": "index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "*", "host": "app",
"name": "Customer Manager" "name": "customer manager"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +1,27 @@
{ {
"name": "feedback-commander", "name": "feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus", "description": "Custom High-Fidelity Management for Directus",
"icon": "view_kanban", "icon": "extension",
"version": "1.7.12", "version": "1.8.2",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"directus", "directus",
"directus-extension", "directus-extension",
"directus-extension-module" "directus-extension-module"
], ],
"files": [
"dist"
],
"directus:extension": { "directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "path": "index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "*", "host": "app",
"name": "Feedback Commander" "name": "feedback commander"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +1,28 @@
{ {
"name": "people-manager", "name": "people-manager",
"description": "Custom High-Fidelity People Management for Directus", "description": "Custom High-Fidelity Management for Directus",
"icon": "person", "icon": "extension",
"version": "1.7.12", "version": "1.8.2",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"directus", "directus",
"directus-extension", "directus-extension",
"directus-extension-module" "directus-extension-module"
], ],
"files": [
"dist"
],
"directus:extension": { "directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "path": "index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "*", "host": "app",
"name": "People Manager" "name": "people manager"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
{
"name": "unified-dashboard",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "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"
}
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/cms-infra", "name": "@mintel/cms-infra",
"version": "1.8.2", "version": "1.8.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -37,7 +37,7 @@ collections:
color: null color: null
display_template: '{{name}}' display_template: '{{name}}'
group: null group: null
hidden: true hidden: false
icon: business icon: business
item_duplication_fields: null item_duplication_fields: null
note: null note: null
@@ -146,6 +146,30 @@ collections:
versioning: false versioning: false
schema: schema:
name: visual_feedback_comments name: visual_feedback_comments
- collection: customers
meta:
accountability: all
archive_app_filter: true
archive_field: null
archive_value: null
collapse: open
collection: customers
color: null
display_template: '{{company.name}}'
group: null
hidden: false
icon: handshake
item_duplication_fields: null
note: null
preview_url: null
singleton: false
sort: null
sort_field: null
translations: null
unarchive_value: null
versioning: false
schema:
name: customers
fields: fields:
- collection: client_users - collection: client_users
field: id field: id
@@ -1959,13 +1983,204 @@ fields:
validation_message: null validation_message: null
width: half width: half
schema: schema:
name: person foreign_key_column: null
table: visual_feedback_comments - collection: customers
field: id
type: uuid
meta:
collection: customers
conditions: null
display: null
display_options: null
field: id
group: null
hidden: true
interface: null
note: null
options: null
readonly: false
required: false
searchable: true
sort: 1
special:
- uuid
translations: null
validation: null
validation_message: null
width: full
schema:
name: id
table: customers
data_type: char data_type: char
default_value: null default_value: null
max_length: 36 max_length: 36
numeric_precision: null numeric_precision: null
numeric_scale: null numeric_scale: null
is_nullable: false
is_unique: true
is_indexed: false
is_primary_key: true
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: customers
field: company
type: uuid
meta:
collection: customers
conditions: null
display: null
display_options: null
field: company
group: null
hidden: false
interface: select-dropdown-m2o
note: null
options: null
readonly: false
required: true
searchable: true
sort: 2
special: null
translations: null
validation: null
validation_message: null
width: half
schema:
name: company
table: customers
data_type: char
default_value: null
max_length: 36
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: companies
foreign_key_column: id
- collection: customers
field: contact_person
type: uuid
meta:
collection: customers
conditions: null
display: null
display_options: null
field: contact_person
group: null
hidden: false
interface: select-dropdown-m2o
note: null
options: null
readonly: false
required: false
searchable: true
sort: 3
special: null
translations: null
validation: null
validation_message: null
width: half
schema:
name: contact_person
table: customers
data_type: char
default_value: null
max_length: 36
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: people
foreign_key_column: id
- collection: customers
field: status
type: string
meta:
collection: customers
conditions: null
display: null
display_options: null
field: status
group: null
hidden: false
interface: select-dropdown
note: null
options:
choices:
- text: Active
value: active
- text: Inactive
value: inactive
readonly: false
required: false
searchable: true
sort: 4
special: null
translations: null
validation: null
validation_message: null
width: half
schema:
name: status
table: customers
data_type: varchar
default_value: active
max_length: 255
numeric_precision: null
numeric_scale: null
is_nullable: true
is_unique: false
is_indexed: false
is_primary_key: false
is_generated: false
generation_expression: null
has_auto_increment: false
foreign_key_table: null
foreign_key_column: null
- collection: customers
field: notes
type: text
meta:
collection: customers
conditions: null
display: null
display_options: null
field: notes
group: null
hidden: false
interface: input-multiline
note: null
options: null
readonly: false
required: false
searchable: true
sort: 5
special: null
translations: null
validation: null
validation_message: null
width: full
schema:
name: notes
table: customers
data_type: text
default_value: null
max_length: null
numeric_precision: null
numeric_scale: null
is_nullable: true is_nullable: true
is_unique: false is_unique: false
is_indexed: false is_indexed: false
@@ -1989,6 +2204,28 @@ systemFields:
schema: schema:
is_indexed: true is_indexed: true
relations: relations:
- collection: customers
field: company
related_collection: companies
schema:
on_update: null
on_delete: SET NULL
constraint_name: customers_company_foreign
table: customers
column: company
foreign_key_table: companies
foreign_key_column: id
- collection: customers
field: contact_person
related_collection: people
schema:
on_update: null
on_delete: SET NULL
constraint_name: customers_contact_person_foreign
table: customers
column: contact_person
foreign_key_table: people
foreign_key_column: id
- collection: client_users - collection: client_users
field: company field: company
related_collection: companies related_collection: companies

View File

@@ -1 +1 @@
xmKX5 --tVj

View File

@@ -1,8 +1,8 @@
{ {
"name": "company-manager", "name": "company-manager",
"description": "Central Company Management for Directus", "description": "Custom High-Fidelity Management for Directus",
"icon": "business", "icon": "extension",
"version": "1.8.2", "version": "1.8.4",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"directus", "directus",
@@ -11,17 +11,18 @@
], ],
"directus:extension": { "directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "path": "dist/index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "*", "host": "app",
"name": "Company Manager" "name": "company manager"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

View File

@@ -0,0 +1,14 @@
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,
},
],
});

View File

@@ -0,0 +1,217 @@
<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 { MintelManagerLayout } from '@mintel/directus-extension-toolkit';
const api = useApi();
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(fetchData);
</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>

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
{ {
"name": "customer-manager", "name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus", "description": "Custom High-Fidelity Management for Directus",
"icon": "supervisor_account", "icon": "extension",
"version": "1.8.2", "version": "1.8.4",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"directus", "directus",
@@ -11,17 +11,18 @@
], ],
"directus:extension": { "directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "path": "dist/index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "*", "host": "app",
"name": "Customer Manager" "name": "customer manager"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

View File

@@ -1,161 +1,191 @@
<template> <template>
<private-view title="Customer Manager"> <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> <template #navigation>
<v-list nav> <v-list nav>
<v-list-item @click="openCreateCompany" clickable> <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-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
<v-list-item-content> <v-list-item-content>
<v-text-overflow text="Neue Firma anlegen" /> <v-text-overflow text="Neuen Kunden verlinken" />
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
<v-divider /> <v-divider />
<v-list-item <v-list-item
v-for="company in companies" v-for="item in items"
:key="company.id" :key="item.id"
:active="selectedCompany?.id === company.id" :active="selectedItem?.id === item.id"
class="company-item" class="nav-item"
clickable clickable
@click="selectCompany(company)" @click="selectItem(item)"
> >
<v-list-item-icon><v-icon name="business" /></v-list-item-icon> <v-list-item-icon><v-icon name="business" /></v-list-item-icon>
<v-list-item-content> <v-list-item-content>
<v-text-overflow :text="company.name" /> <v-text-overflow :text="item.company?.name" />
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
</v-list> </v-list>
</template> </template>
<template #title-outer:after> <template #subtitle>
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible> <template v-if="selectedItem">
{{ notice.message }} {{ clientUsers.length }} Portal-Nutzer &middot; {{ selectedItem.company?.domain }}
</v-notice> </template>
</template> </template>
<div class="content-wrapper"> <template #actions>
<div v-if="!selectedCompany" class="empty-state"> <v-button secondary rounded icon v-tooltip.bottom="'Kunden-Verlinkung bearbeiten'" @click="openEditDrawer">
<v-info title="Firmen auswählen" icon="business" center> <v-icon name="edit" />
Wähle eine Firma in der Navigation aus oder </v-button>
<v-button x-small @click="openCreateCompany">erstelle eine neue Firma</v-button>. <v-button primary @click="openCreateClientUser">
</v-info> Portal-Nutzer hinzufügen
</div> </v-button>
</template>
<template v-else> <template #empty-state>
<header class="header"> Wähle einen Kunden aus der Liste oder
<div class="header-left"> <v-button x-small @click="openCreateDrawer">verlinke eine neue Firma</v-button>.
<h1 class="title">{{ selectedCompany.name }}</h1> </template>
<p class="subtitle">{{ employees.length }} Kunden-Mitarbeiter</p>
</div>
<div class="header-right">
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditCompany">
<v-icon name="edit" />
</v-button>
<v-button primary @click="openCreateEmployee">
Mitarbeiter hinzufügen
</v-button>
</div>
</header>
<v-table <!-- Main Content: Client Users Table -->
:headers="tableHeaders" <v-table
:items="employees" :headers="tableHeaders"
:loading="loading" :items="clientUsers"
class="clickable-table" :loading="loading"
fixed-header class="clickable-table"
@click:row="onRowClick" 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>
</template>
</div>
<!-- Drawer: Company Form -->
<v-drawer
v-model="drawerCompanyActive"
:title="isEditingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
icon="business"
@cancel="drawerCompanyActive = false"
> >
<div v-if="drawerCompanyActive" class="drawer-content"> <template #[`item.name`]="{ item }">
<div class="form-section"> <div class="user-cell">
<div class="field"> <v-avatar :name="item.first_name" x-small />
<span class="label">Firmenname</span> <span class="user-name">{{ item.first_name }} {{ item.last_name }}</span>
<v-input v-model="companyForm.name" placeholder="z.B. KLZ Cables" autofocus /> </div>
</div> </template>
</div>
<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"> <div class="drawer-actions">
<v-button primary block :loading="saving" @click="saveCompany">Speichern</v-button> <v-button primary block :loading="saving" @click="saveItem">Speichern</v-button>
</div> </div>
</div> </div>
</v-drawer> </v-drawer>
<!-- Drawer: Employee Form --> <!-- Drawer: Client User Form -->
<v-drawer <v-drawer
v-model="drawerEmployeeActive" v-model="drawerUserActive"
:title="isEditingEmployee ? 'Mitarbeiter bearbeiten' : 'Neuen Mitarbeiter anlegen'" :title="isEditingUser ? 'Portal-Nutzer bearbeiten' : 'Neuen Portal-Nutzer anlegen'"
icon="person" icon="person"
@cancel="drawerEmployeeActive = false" @cancel="drawerUserActive = false"
> >
<div v-if="drawerEmployeeActive" class="drawer-content"> <div v-if="drawerUserActive" class="drawer-content">
<div class="form-section"> <div class="form-section">
<div class="field"> <div class="field">
<span class="label">Vorname</span> <span class="label">Vorname</span>
<v-input v-model="employeeForm.first_name" placeholder="Vorname" autofocus /> <v-input v-model="userForm.first_name" placeholder="Vorname" autofocus />
</div> </div>
<div class="field"> <div class="field">
<span class="label">Nachname</span> <span class="label">Nachname</span>
<v-input v-model="employeeForm.last_name" placeholder="Nachname" /> <v-input v-model="userForm.last_name" placeholder="Nachname" />
</div> </div>
<div class="field"> <div class="field">
<span class="label">E-Mail</span> <span class="label">E-Mail</span>
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" /> <v-input v-model="userForm.email" placeholder="E-Mail Adresse" type="email" />
</div> </div>
<div class="field"> <div class="field">
<span class="label">Zentrale Person (Verknüpfung)</span> <span class="label">Zentrale Person (Verknüpfung)</span>
<v-select <v-select
v-model="employeeForm.person" v-model="userForm.person"
:items="peopleOptions" :items="peopleOptions"
placeholder="Person aus dem People Manager auswählen..." placeholder="Master-Person auswählen..."
/> />
</div> </div>
<v-divider v-if="isEditingEmployee" /> <v-divider v-if="isEditingUser" />
<div v-if="isEditingEmployee" class="field"> <div v-if="isEditingUser" class="field">
<span class="label">Temporäres Passwort</span> <span class="label">Temporäres Passwort</span>
<v-input v-model="employeeForm.temporary_password" readonly class="password-input" /> <v-input v-model="userForm.temporary_password" readonly class="password-input" />
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p> <p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
</div> </div>
</div> </div>
<div class="drawer-actions"> <div class="drawer-actions">
<v-button primary block :loading="saving" @click="saveEmployee">Daten speichern</v-button> <v-button primary block :loading="saving" @click="saveClientUser">Daten speichern</v-button>
<template v-if="isEditingEmployee"> <template v-if="isEditingUser">
<v-divider /> <v-divider />
<v-button <v-button
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'" v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
secondary secondary
block block
:loading="invitingId === employeeForm.id" :loading="invitingId === userForm.id"
@click="inviteUser(employeeForm)" @click="inviteUser(userForm)"
> >
<v-icon name="send" left /> Zugangsdaten senden <v-icon name="send" left /> Zugangsdaten senden
</v-button> </v-button>
@@ -163,38 +193,34 @@
</div> </div>
</div> </div>
</v-drawer> </v-drawer>
</private-view> </MintelManagerLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick, computed } from 'vue'; import { ref, onMounted, nextTick, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
const api = useApi(); const api = useApi();
const companies = ref<any[]>([]); const items = ref<any[]>([]);
const selectedCompany = ref<any>(null); const selectedItem = ref<any>(null);
const employees = ref<any[]>([]); const clientUsers = ref<any[]>([]);
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const invitingId = ref<string | null>(null); const invitingId = ref<string | null>(null);
const notice = ref<{ type: string; message: string } | null>(null); const notice = ref<{ type: string; message: string } | null>(null);
// Forms State const companies = ref<any[]>([]);
const drawerCompanyActive = ref(false); const people = ref<any[]>([]);
const isEditingCompany = ref(false);
const companyForm = ref({ id: '', name: '' });
const drawerEmployeeActive = ref(false); const drawerActive = ref(false);
const isEditingEmployee = ref(false); const isEditing = ref(false);
const employeeForm = ref({ const form = ref({ id: null, company: null, contact_person: null, status: 'active', notes: '' });
id: '',
first_name: '', const drawerUserActive = ref(false);
last_name: '', const isEditingUser = ref(false);
email: '', const userForm = ref({ id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' });
person: null,
temporary_password: ''
});
const tableHeaders = [ const tableHeaders = [
{ text: 'Name', value: 'name', sortable: true }, { text: 'Name', value: 'name', sortable: true },
@@ -202,180 +228,159 @@ const tableHeaders = [
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true } { text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
]; ];
const people = 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} (${p.email})`, value: p.id })));
const peopleOptions = computed(() =>
people.value.map(p => ({
text: `${p.first_name} ${p.last_name} (${p.email})`,
value: p.id
}))
);
async function fetchData() { async function fetchData() {
const [companiesResp, peopleResp] = await Promise.all([ loading.value = true;
api.get('/items/companies', { params: { sort: 'name', fields: ['id', 'name'] } }), try {
api.get('/items/people', { params: { sort: 'last_name' } }) const [custResp, compResp, peopleResp] = await Promise.all([
]); api.get('/items/customers', { params: { fields: ['*', 'company.*', 'contact_person.*'], sort: 'company.name' } }),
companies.value = companiesResp.data.data; api.get('/items/companies', { params: { sort: 'name' } }),
people.value = peopleResp.data.data; 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 selectCompany(company: any) { async function selectItem(item: any) {
selectedCompany.value = company; selectedItem.value = item;
loading.value = true; loading.value = true;
try { try {
const res = await api.get('/items/client_users', { const res = await api.get('/items/client_users', {
params: { params: {
filter: { company: { _eq: company.id } }, filter: { company: { _eq: item.company.id } },
fields: ['*', 'person.*'], fields: ['*', 'person.*'],
sort: 'first_name', sort: 'first_name',
}, },
}); });
employees.value = res.data.data; clientUsers.value = res.data.data;
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
// Company Actions function openCreateDrawer() {
function openCreateCompany() { isEditing.value = false;
isEditingCompany.value = false; form.value = { id: null, company: null, contact_person: null, status: 'active', notes: '' };
companyForm.value = { id: '', name: '' }; drawerActive.value = true;
drawerCompanyActive.value = true;
} }
async function openEditCompany() { function openEditDrawer() {
if (!selectedCompany.value) return; if (!selectedItem.value) return;
companyForm.value = { isEditing.value = true;
id: selectedCompany.value.id, form.value = {
name: selectedCompany.value.name 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
}; };
isEditingCompany.value = true; drawerActive.value = true;
await nextTick();
drawerCompanyActive.value = true;
} }
async function saveCompany() { async function saveItem() {
if (!companyForm.value.name) return; if (!form.value.company) return;
saving.value = true; saving.value = true;
try { try {
if (isEditingCompany.value) { if (isEditing.value) {
await api.patch(`/items/companies/${companyForm.value.id}`, { name: companyForm.value.name }); await api.patch(`/items/customers/${form.value.id}`, form.value);
notice.value = { type: 'success', message: 'Firma aktualisiert!' }; notice.value = { type: 'success', message: 'Kunde aktualisiert!' };
} else { } else {
await api.post('/items/companies', { name: companyForm.value.name }); await api.post('/items/customers', form.value);
notice.value = { type: 'success', message: 'Firma angelegt!' }; notice.value = { type: 'success', message: 'Neuer Kunde verlinkt!' };
} }
drawerCompanyActive.value = false; drawerActive.value = false;
await fetchCompanies(); await fetchData();
if (selectedCompany.value?.id === companyForm.value.id) { if (form.value.id) {
selectedCompany.value.name = companyForm.value.name; const updated = items.value.find(i => i.id === form.value.id);
} if (updated) selectItem(updated);
} catch (e: any) { }
notice.value = { type: 'danger', message: e.message }; } catch (e: any) {
} finally { notice.value = { type: 'danger', message: e.message };
saving.value = false; } finally {
} saving.value = false;
}
} }
// Employee Actions // Client User Actions
function openCreateEmployee() { function openCreateClientUser() {
isEditingEmployee.value = false; isEditingUser.value = false;
employeeForm.value = { id: '', first_name: '', last_name: '', email: '', temporary_password: '' }; userForm.value = { id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' };
drawerEmployeeActive.value = true; drawerUserActive.value = true;
}
async function openEditEmployee(item: any) {
employeeForm.value = {
id: item.id || '',
first_name: item.first_name || '',
last_name: item.last_name || '',
email: item.email || '',
person: item.person?.id || item.person || null,
temporary_password: item.temporary_password || ''
};
isEditingEmployee.value = true;
await nextTick();
drawerEmployeeActive.value = true;
}
async function saveEmployee() {
if (!employeeForm.value.email || !selectedCompany.value) return;
saving.value = true;
try {
if (isEditingEmployee.value) {
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email,
person: employeeForm.value.person
});
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
} else {
await api.post('/items/client_users', {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email,
company: selectedCompany.value.id,
person: employeeForm.value.person
});
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
}
drawerEmployeeActive.value = false;
await selectCompany(selectedCompany.value);
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
saving.value = false;
}
}
async function inviteUser(user: any) {
invitingId.value = user.id;
try {
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
notice.value = { type: 'success', message: `Zugangsdaten für ${user.first_name} versendet. 📧` };
await selectCompany(selectedCompany.value);
if (drawerEmployeeActive.value && employeeForm.value.id === user.id) {
const updated = employees.value.find(e => e.id === user.id);
if (updated) {
employeeForm.value.temporary_password = updated.temporary_password;
}
}
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler: ${e.message}` };
} finally {
invitingId.value = null;
}
} }
function onRowClick(event: any) { function onRowClick(event: any) {
const item = event?.item || event; const item = event?.item || event;
if (item && item.id) { if (item && item.id) {
openEditEmployee(item); 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') {
// Quick add logic can involve opening another drawer or navigating
// For now, we'll just show a notice
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` };
} }
function formatDate(dateStr: string) { function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('de-DE', { return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric', day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit' hour: '2-digit', minute: '2-digit'
}); });
} }
onMounted(() => { onMounted(fetchData);
fetchData();
});
</script> </script>
<style scoped> <style scoped>
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; }
.company-item { cursor: pointer; }
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
.header-right { display: flex; gap: 12px; }
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; }
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; }
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
.user-cell { display: flex; align-items: center; gap: 12px; } .user-cell { display: flex; align-items: center; gap: 12px; }
.user-name { font-weight: 600; } .user-name { font-weight: 600; }
.status-date { font-size: 12px; color: var(--theme--foreground-subdued); } .status-date { font-size: 12px; color: var(--theme--foreground-subdued); }
@@ -385,6 +390,7 @@ onMounted(() => {
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; } .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; } .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; } .drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
.password-input :deep(textarea) { .password-input :deep(textarea) {
font-family: var(--family-monospace); font-family: var(--family-monospace);
font-weight: 800; font-weight: 800;
@@ -394,5 +400,4 @@ onMounted(() => {
.clickable-table :deep(tbody tr) { cursor: pointer; transition: background-color 0.2s ease; } .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; } .clickable-table :deep(tbody tr:hover) { background-color: var(--theme--background-subdued) !important; }
:deep(.v-list-item) { cursor: pointer !important; }
</style> </style>

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/directus-extension-toolkit", "name": "@mintel/directus-extension-toolkit",
"version": "1.8.2", "version": "1.8.4",
"description": "Shared toolkit for Directus extensions in the Mintel ecosystem", "description": "Shared toolkit for Directus extensions in the Mintel ecosystem",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",

View File

@@ -1,7 +1,17 @@
import js from "@eslint/js"; import js from "@eslint/js";
import tseslint from "typescript-eslint"; import tseslint from "typescript-eslint";
import globals from "globals";
export default tseslint.config( export default tseslint.config(
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.es2021,
},
},
},
{ {
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**", "**/build/**"], ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**", "**/build/**"],
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/eslint-config", "name": "@mintel/eslint-config",
"version": "1.8.2", "version": "1.8.4",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,8 @@
{ {
"name": "feedback-commander", "name": "feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus", "description": "Custom High-Fidelity Management for Directus",
"icon": "view_kanban", "icon": "extension",
"version": "1.8.2", "version": "1.8.4",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"directus", "directus",
@@ -11,13 +11,13 @@
], ],
"directus:extension": { "directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "path": "dist/index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "*", "host": "app",
"name": "Feedback Commander" "name": "feedback commander"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/gatekeeper", "name": "@mintel/gatekeeper",
"version": "1.8.2", "version": "1.8.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/husky-config", "name": "@mintel/husky-config",
"version": "1.8.2", "version": "1.8.4",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"

View File

@@ -5,7 +5,7 @@ on:
branches: branches:
- main - main
tags: tags:
- 'v*' - '*'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
skip_long_checks: skip_long_checks:
@@ -65,11 +65,6 @@ jobs:
PRJ_ID="${{ github.event.repository.name }}" PRJ_ID="${{ github.event.repository.name }}"
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
if [[ "$COMMIT_MSG" =~ ^chore: ]]; then
TARGET="skip"
GOTIFY_TITLE=" Skip Deploy (Chore)"
GOTIFY_PRIORITY=2
else
TARGET="testing" TARGET="testing"
IMAGE_TAG="main-${SHORT_SHA}" IMAGE_TAG="main-${SHORT_SHA}"
ENV_FILE=".env.testing" ENV_FILE=".env.testing"
@@ -81,9 +76,8 @@ jobs:
IS_PROD="false" IS_PROD="false"
GOTIFY_TITLE="🧪 Testing-Deploy" GOTIFY_TITLE="🧪 Testing-Deploy"
GOTIFY_PRIORITY=4 GOTIFY_PRIORITY=4
fi
elif [[ "${{ github.ref_type }}" == "tag" ]]; then elif [[ "${{ github.ref_type }}" == "tag" ]]; then
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if [[ "$TAG" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
TARGET="production" TARGET="production"
IMAGE_TAG="$TAG" IMAGE_TAG="$TAG"
ENV_FILE=".env.prod" ENV_FILE=".env.prod"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/infra", "name": "@mintel/infra",
"version": "1.8.2", "version": "1.8.4",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/mail", "name": "@mintel/mail",
"version": "1.8.2", "version": "1.8.4",
"private": false, "private": false,
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/next-config", "name": "@mintel/next-config",
"version": "1.8.2", "version": "1.8.4",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/next-feedback", "name": "@mintel/next-feedback",
"version": "1.8.2", "version": "1.8.4",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"

View File

@@ -31,8 +31,20 @@ interface Feedback {
comments: FeedbackComment[]; comments: FeedbackComment[];
} }
export function FeedbackOverlay() { export function FeedbackOverlay({
const [isActive, setIsActive] = useState(false); isActive: externalIsActive,
onActiveChange
}: {
isActive?: boolean;
onActiveChange?: (active: boolean) => void
}) {
const [internalIsActive, setInternalIsActive] = useState(false);
const isActive = externalIsActive !== undefined ? externalIsActive : internalIsActive;
const setIsActive = (val: boolean) => {
if (externalIsActive === undefined) setInternalIsActive(val);
onActiveChange?.(val);
};
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>( const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(
null, null,
); );

View File

@@ -1,2 +1,2 @@
export * from "./handlers"; export * from "./handlers";
export * from "./components/FeedbackOverlay"; export { FeedbackOverlay } from "./components/FeedbackOverlay";

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/next-observability", "name": "@mintel/next-observability",
"version": "1.8.2", "version": "1.8.4",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/next-utils", "name": "@mintel/next-utils",
"version": "1.8.2", "version": "1.8.4",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/observability", "name": "@mintel/observability",
"version": "1.8.2", "version": "1.8.4",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/pdf", "name": "@mintel/pdf",
"version": "1.8.2", "version": "1.8.4",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",

View File

@@ -525,13 +525,13 @@ export const FoldingMarks = () => (
export const Footer = ({ export const Footer = ({
logo, logo,
companyData, companyData,
bankData, _bankData,
showDetails = true, showDetails = true,
showPageNumber = true, showPageNumber = true,
}: { }: {
logo?: string; logo?: string;
companyData: any; companyData: any;
bankData?: any; _bankData?: any;
showDetails?: boolean; showDetails?: boolean;
showPageNumber?: boolean; showPageNumber?: boolean;
}) => ( }) => (

View File

@@ -8,7 +8,6 @@ import {
} from "@react-pdf/renderer"; } from "@react-pdf/renderer";
import { import {
DocumentTitle, DocumentTitle,
IndustrialListItem,
IndustrialCard, IndustrialCard,
Divider, Divider,
COLORS, COLORS,

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,8 @@
{ {
"name": "people-manager", "name": "people-manager",
"id": "people-manager", "description": "Custom High-Fidelity Management for Directus",
"description": "Custom High-Fidelity People Management for Directus", "icon": "extension",
"icon": "person", "version": "1.8.4",
"version": "1.8.2",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"directus", "directus",
@@ -11,19 +10,19 @@
"directus-extension-module" "directus-extension-module"
], ],
"directus:extension": { "directus:extension": {
"id": "people-manager",
"type": "module", "type": "module",
"path": "index.js", "path": "dist/index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "*", "host": "app",
"name": "People Manager" "name": "people manager"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"vue": "^3.4.0" "vue": "^3.4.0"
} }
} }

View File

@@ -1,5 +1,13 @@
<template> <template>
<private-view title="People Manager"> <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> <template #navigation>
<v-list nav> <v-list nav>
<v-list-item @click="openCreateDrawer" clickable> <v-list-item @click="openCreateDrawer" clickable>
@@ -17,7 +25,7 @@
v-for="person in people" v-for="person in people"
:key="person.id" :key="person.id"
:active="selectedPerson?.id === person.id" :active="selectedPerson?.id === person.id"
class="person-item" class="nav-item"
clickable clickable
@click="selectPerson(person)" @click="selectPerson(person)"
> >
@@ -31,47 +39,42 @@
</v-list> </v-list>
</template> </template>
<div class="content-wrapper"> <template #subtitle>
<v-notice v-if="feedback" :type="feedback.type" @close="feedback = null" dismissible> <template v-if="selectedPerson">
{{ feedback.message }} {{ getCompanyName(selectedPerson) }}
</v-notice> </template>
</template>
<div v-if="!selectedPerson" class="empty-state"> <template #actions>
<v-info title="Person auswählen" icon="person" center> <v-button secondary rounded icon v-tooltip.bottom="'Person bearbeiten'" @click="openEditDrawer">
Wähle eine Person in der Navigation aus oder <v-icon name="edit" />
<v-button x-small @click="openCreateDrawer">erstelle eine neue Person</v-button>. </v-button>
</v-info> <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>
<div class="detail-item">
<div v-else> <span class="label">Nachname</span>
<header class="header"> <p class="value">{{ selectedPerson.last_name }}</p>
<div class="header-left"> </div>
<h1 class="title">{{ selectedPerson.first_name }} {{ selectedPerson.last_name }}</h1> <div class="detail-item">
<p class="subtitle">{{ selectedPerson.email || 'Keine E-Mail angegeben' }}</p> <span class="label">E-Mail</span>
</div> <p class="value">{{ selectedPerson.email || '---' }}</p>
</div>
<div class="header-right"> <div class="detail-item">
<v-button secondary rounded icon v-tooltip="'Person bearbeiten'" @click="openEditDrawer"> <span class="label">Organisation</span>
<v-icon name="edit" /> <p class="value">{{ getCompanyName(selectedPerson) }}</p>
</v-button>
<v-button danger rounded icon v-tooltip="'Person löschen'" @click="deletePerson">
<v-icon name="delete" />
</v-button>
</div>
</header>
<v-divider />
<div class="details-grid">
<div class="detail-item">
<span class="label">Organisation / Firma</span>
<p class="value">{{ getCompanyName(selectedPerson) }}</p>
</div>
<div class="detail-item">
<span class="label">Telefon</span>
<p class="value">{{ selectedPerson.phone || '---' }}</p>
</div>
</div>
</div> </div>
</div> </div>
@@ -95,24 +98,18 @@
</div> </div>
<div class="field"> <div class="field">
<span class="label">E-Mail</span> <span class="label">E-Mail</span>
<v-input v-model="form.email" placeholder="email@beispiel.de" type="email" /> <v-input v-model="form.email" placeholder="E-Mail Adresse" type="email" />
</div> </div>
<div class="field"> <div class="field">
<span class="label">Zentrale Firma</span> <span class="label">Zentrale Firma</span>
<v-select <MintelSelect
v-model="form.company" v-model="form.company"
:items="companyOptions" :items="companyOptions"
placeholder="Bestehende Firma auswählen..." placeholder="Bestehende Firma auswählen..."
allow-add
@add="openQuickAdd('company')"
/> />
</div> </div>
<div class="field">
<span class="label">Firma (Legacy / Neu)</span>
<v-input v-model="form.company_name" placeholder="z.B. Mintel" />
</div>
<div class="field">
<span class="label">Telefon</span>
<v-input v-model="form.phone" placeholder="+49 ..." />
</div>
</div> </div>
<div class="drawer-actions"> <div class="drawer-actions">
@@ -123,12 +120,13 @@
</div> </div>
</template> </template>
</v-drawer> </v-drawer>
</private-view> </MintelManagerLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed, nextTick } from 'vue';
import { useApi } from '@directus/extensions-sdk'; import { useApi } from '@directus/extensions-sdk';
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
const api = useApi(); const api = useApi();
const people = ref([]); const people = ref([]);
@@ -144,9 +142,7 @@ const form = ref({
first_name: '', first_name: '',
last_name: '', last_name: '',
email: '', email: '',
company: null, company: null
company_name: '',
phone: ''
}); });
const companyOptions = computed(() => const companyOptions = computed(() =>
@@ -159,9 +155,9 @@ const companyOptions = computed(() =>
function getCompanyName(person: any) { function getCompanyName(person: any) {
if (!person) return '---'; if (!person) return '---';
if (person.company) { if (person.company) {
return typeof person.company === 'object' ? person.company.name : (companies.value.find(c => c.id === person.company)?.name || person.company_name); return typeof person.company === 'object' ? person.company.name : (companies.value.find(c => c.id === person.company)?.name || 'Unbekannte Firma');
} }
return person.company_name || '---'; return '---';
} }
async function fetchData() { async function fetchData() {
@@ -194,10 +190,8 @@ function openCreateDrawer() {
id: null, id: null,
first_name: '', first_name: '',
last_name: '', last_name: '',
email: '', email: '',
company: null, company: null
company_name: '',
phone: ''
}; };
drawerActive.value = true; drawerActive.value = true;
} }
@@ -205,23 +199,12 @@ function openCreateDrawer() {
function openEditDrawer() { function openEditDrawer() {
isEditing.value = true; isEditing.value = true;
const person = selectedPerson.value; const person = selectedPerson.value;
let companyId = null;
let companyName = person.company_name || '';
if (person.company) {
if (typeof person.company === 'object') {
companyId = person.company.id;
} else if (person.company.length === 36) { // Assume UUID
companyId = person.company;
} else {
companyName = person.company;
}
}
form.value = { form.value = {
...person, id: person.id,
company: companyId, first_name: person.first_name,
company_name: companyName last_name: person.last_name,
email: person.email,
company: person.company?.id || person.company
}; };
drawerActive.value = true; drawerActive.value = true;
} }
@@ -234,17 +217,20 @@ async function savePerson() {
saving.value = true; saving.value = true;
try { try {
let updatedItem;
if (isEditing.value) { if (isEditing.value) {
await api.patch(`/items/people/${form.value.id}`, form.value); const res = await api.patch(`/items/people/${form.value.id}`, form.value);
updatedItem = res.data.data;
feedback.value = { type: 'success', message: 'Person aktualisiert!' }; feedback.value = { type: 'success', message: 'Person aktualisiert!' };
} else { } else {
await api.post('/items/people', form.value); const res = await api.post('/items/people', form.value);
updatedItem = res.data.data;
feedback.value = { type: 'success', message: 'Person angelegt!' }; feedback.value = { type: 'success', message: 'Person angelegt!' };
} }
drawerActive.value = false; drawerActive.value = false;
await fetchData(); await fetchData();
if (isEditing.value) { if (updatedItem) {
selectedPerson.value = people.value.find(p => p.id === form.value.id); selectedPerson.value = people.value.find(p => p.id === updatedItem.id) || updatedItem;
} }
} catch (error) { } catch (error) {
feedback.value = { type: 'danger', message: error.message }; feedback.value = { type: 'danger', message: error.message };
@@ -266,50 +252,18 @@ async function deletePerson() {
} }
} }
function openQuickAdd(type: string) {
feedback.value = { type: 'info', message: `Firma im Company Manager anlegen.` };
}
onMounted(fetchData); onMounted(fetchData);
</script> </script>
<style scoped> <style scoped>
.content-wrapper {
padding: 32px;
height: 100%;
}
.header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.title {
font-size: 24px;
font-weight: 800;
margin-bottom: 4px;
}
.subtitle {
color: var(--theme--foreground-subdued);
font-size: 14px;
}
.header-right {
display: flex;
gap: 12px;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.details-grid { .details-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 32px; gap: 32px;
margin-top: 32px;
} }
.detail-item { .detail-item {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/tsconfig", "name": "@mintel/tsconfig",
"version": "1.8.2", "version": "1.8.4",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"

View File

@@ -1,8 +1,8 @@
{ {
"name": "unified-dashboard", "name": "unified-dashboard",
"description": "Unified Infrastructure Dashboard for Directus", "description": "Custom High-Fidelity Management for Directus",
"icon": "dashboard", "icon": "extension",
"version": "1.8.2", "version": "1.8.4",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"directus", "directus",
@@ -11,13 +11,13 @@
], ],
"directus:extension": { "directus:extension": {
"type": "module", "type": "module",
"path": "index.js", "path": "dist/index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "*", "host": "app",
"name": "Unified Dashboard" "name": "unified dashboard"
}, },
"scripts": { "scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true", "build": "directus-extension build",
"dev": "directus-extension build -w" "dev": "directus-extension build -w"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,14 @@
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,
},
],
});

View File

@@ -0,0 +1,141 @@
<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>
</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(path: string) {
router.push(path);
}
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>

21
pnpm-lock.yaml generated
View File

@@ -12,6 +12,9 @@ importers:
.: .:
dependencies: dependencies:
globals:
specifier: ^17.3.0
version: 17.3.0
import-in-the-middle: import-in-the-middle:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
@@ -180,6 +183,9 @@ importers:
'@directus/extensions-sdk': '@directus/extensions-sdk':
specifier: 11.0.2 specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3) version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue: vue:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3) version: 3.5.28(typescript@5.9.3)
@@ -247,6 +253,9 @@ importers:
'@directus/extensions-sdk': '@directus/extensions-sdk':
specifier: 11.0.2 specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3) version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue: vue:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3) version: 3.5.28(typescript@5.9.3)
@@ -256,6 +265,9 @@ importers:
'@directus/extensions-sdk': '@directus/extensions-sdk':
specifier: 11.0.2 specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3) version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue: vue:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3) version: 3.5.28(typescript@5.9.3)
@@ -631,6 +643,9 @@ importers:
'@directus/extensions-sdk': '@directus/extensions-sdk':
specifier: 11.0.2 specifier: 11.0.2
version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3) version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/directus-extension-toolkit':
specifier: workspace:*
version: link:../directus-extension-toolkit
vue: vue:
specifier: ^3.4.0 specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3) version: 3.5.28(typescript@5.9.3)
@@ -5109,6 +5124,10 @@ packages:
resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
engines: {node: '>=18'} engines: {node: '>=18'}
globals@17.3.0:
resolution: {integrity: sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==}
engines: {node: '>=18'}
globalthis@1.0.4: globalthis@1.0.4:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -13103,6 +13122,8 @@ snapshots:
globals@16.4.0: {} globals@16.4.0: {}
globals@17.3.0: {}
globalthis@1.0.4: globalthis@1.0.4:
dependencies: dependencies:
define-properties: 1.2.1 define-properties: 1.2.1

View File

@@ -4,10 +4,15 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
EXTENSIONS_ROOT="$REPO_ROOT/packages" EXTENSIONS_ROOT="$REPO_ROOT/packages"
TARGET_DIR="$REPO_ROOT/packages/cms-infra/extensions"
# List of extensions to sync - including modules and endpoints # Strict local targets for bombproof isolation
EXTENSIONS=( TARGET_DIRS=(
"$REPO_ROOT/packages/cms-infra/extensions"
"$REPO_ROOT/directus/extensions"
)
# List of extension packages to sync
EXTENSION_PACKAGES=(
"acquisition" "acquisition"
"acquisition-manager" "acquisition-manager"
"company-manager" "company-manager"
@@ -17,60 +22,134 @@ EXTENSIONS=(
"unified-dashboard" "unified-dashboard"
) )
echo "🚀 Starting extension sync..." echo "🚀 Starting isolated extension sync..."
# Ensure target directory exists # Ensure target directories exist
mkdir -p "$TARGET_DIR" for TARGET in "${TARGET_DIRS[@]}"; do
mkdir -p "$TARGET"
done
# Build the acquisition library first so extensions use the updated build # Build the acquisition library if it exists
echo "📦 Building acquisition-library..." if [ -d "$REPO_ROOT/packages/acquisition" ]; then
(cd "$REPO_ROOT/packages/acquisition-library" && pnpm build) echo "📦 Building acquisition..."
(cd "$REPO_ROOT/packages/acquisition" && pnpm build)
fi
for EXT in "${EXTENSIONS[@]}"; do for PKG in "${EXTENSION_PACKAGES[@]}"; do
EXT_PATH="$EXTENSIONS_ROOT/$EXT" PKG_PATH="$EXTENSIONS_ROOT/$PKG"
if [ -d "$EXT_PATH" ]; then if [ -d "$PKG_PATH" ]; then
echo "📦 Building $EXT..." echo "📦 Processing $PKG..."
# Build the extension # 1. Build the extension
# We use --if-present to avoid errors if build script is missing (cd "$PKG_PATH" && pnpm build)
(cd "$EXT_PATH" && pnpm build)
# Create target directory for this extension EXT_NAME="$PKG"
# Directus expects extensions to be in subdirectories matching their name echo "🚚 Syncing $EXT_NAME..."
mkdir -p "$TARGET_DIR/$EXT"
echo "🚚 Syncing $EXT to $TARGET_DIR/$EXT..."
# Clean target first to avoid ghost files
rm -rf "${TARGET_DIR:?}/$EXT"/*
# Copy build artifacts and package metadata
# Some extensions have index.js in root after build, some use dist/
# We check for index.js and package.json
if [ -f "$EXT_PATH/index.js" ]; then
cp "$EXT_PATH/index.js" "$TARGET_DIR/$EXT/"
fi
if [ -f "$EXT_PATH/package.json" ]; then
cp "$EXT_PATH/package.json" "$TARGET_DIR/$EXT/"
fi
if [ -d "$EXT_PATH/dist" ]; then # 3. Sync to each target directory
cp -r "$EXT_PATH/dist" "$TARGET_DIR/$EXT/" for TARGET_BASE in "${TARGET_DIRS[@]}"; do
fi # FLAT STRUCTURE: Directus 11.15.x local scanner is FLAT.
FINAL_TARGET="$TARGET_BASE/$EXT_NAME"
# Sync node_modules if they exist (sometimes needed if not everything is bundled)
# Deactivated: Causes global scope pollution and login issues in Directus echo "🚚 Syncing $EXT_NAME to $FINAL_TARGET..."
# if [ -d "$EXT_PATH/node_modules" ]; then
# echo "📚 Syncing node_modules for $EXT..." # Clean target first to avoid ghost files
# rsync -aL --delete "$EXT_PATH/node_modules/" "$TARGET_DIR/$EXT/node_modules/" mkdir -p "$FINAL_TARGET"
# fi 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
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
echo "$EXT synced." if [ -d "$PKG_PATH/dist" ]; then
cp -r "$PKG_PATH/dist" "$FINAL_TARGET/"
fi
done
echo "$PKG synced."
else else
echo "❌ Extension source not found: $EXT_PATH" echo "❌ Extension source not found: $PKG_PATH"
fi fi
done done
echo "✨ Extension sync complete!" # Cleanup: remove anything from extensions root that isn't in our whitelist
WHITELIST=("${EXTENSION_PACKAGES[@]}" "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
echo "🔧 Applying core patch to Directus 11.15.2 bundler..."
docker exec cms-infra-infra-cms-1 node -e '
const fs = require("node:fs");
const path = "/directus/node_modules/.pnpm/@directus+extensions@file+packages+extensions_deep-diff@1.0.2_express@4.21.2_graphql@16_244b87fbecd929c2d2240e7b3abc1fe4/node_modules/@directus/extensions/dist/node.js";
if (fs.existsSync(path)) {
let content = fs.readFileSync(path, "utf8");
// Patch the filter: allow string entrypoints for modules
const filterPatch = "extension.host === \"app\" && (extension.entrypoint.app || extension.entrypoint)";
if (!content.includes(filterPatch)) {
content = content.replace(
/extension\.host === \"app\" && !!extension\.entrypoint\.app/g,
filterPatch
);
}
// Patch all imports: handle string entrypoints (replace all occurrences of .app where it might fail)
if (!content.includes("(extension.entrypoint.app || extension.entrypoint)")) {
content = content.replace(
/extension\.entrypoint\.app/g,
"(extension.entrypoint.app || extension.entrypoint)"
);
}
fs.writeFileSync(path, content);
console.log("✅ Core patched successfully.");
} else {
console.error("⚠️ Could not find node.js to patch!");
}
'
echo "🔄 Restarting Directus container..."
docker restart cms-infra-infra-cms-1 2>/dev/null || true
echo "✨ Sync complete! Extensions are in packages/cms-infra/extensions and core is patched."

View File

@@ -8,7 +8,7 @@ import { execSync } from "child_process";
*/ */
function getVersionTag() { function getVersionTag() {
// 0. Check arguments (passed from husky hook or manual run) // 0. Check arguments (passed from husky hook or manual run)
const argTag = process.argv.slice(2).find((arg) => arg.startsWith("v")); const argTag = process.argv.slice(2).find((arg) => arg.match(/^v?\d/));
if (argTag) { if (argTag) {
return argTag; return argTag;
} }
@@ -16,11 +16,11 @@ function getVersionTag() {
// 1. Check CI environment variables // 1. Check CI environment variables
if ( if (
process.env.GITHUB_REF_NAME && process.env.GITHUB_REF_NAME &&
process.env.GITHUB_REF_NAME.startsWith("v") process.env.GITHUB_REF_NAME.match(/^v?\d/)
) { ) {
return process.env.GITHUB_REF_NAME; return process.env.GITHUB_REF_NAME;
} }
if (process.env.TAG && process.env.TAG.startsWith("v")) { if (process.env.TAG && process.env.TAG.match(/^v?\d/)) {
return process.env.TAG; return process.env.TAG;
} }
@@ -29,10 +29,10 @@ function getVersionTag() {
const gitTag = execSync("git describe --tags --abbrev=0", { const gitTag = execSync("git describe --tags --abbrev=0", {
encoding: "utf8", encoding: "utf8",
}).trim(); }).trim();
if (gitTag && gitTag.startsWith("v")) { if (gitTag && gitTag.match(/^v?\d/)) {
return gitTag; return gitTag;
} }
} catch (e) { } catch (_e) {
// Fallback or silence // Fallback or silence
} }

View File

@@ -0,0 +1,67 @@
#!/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."

100
scripts/validate-sdk-imports.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/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

View File

@@ -0,0 +1,46 @@
#!/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