Compare commits

..

26 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
73ea958655 chore: remove [skip ci] from version sync and update image tag 2026-02-13 14:31:30 +01:00
f2035d79dd chore: automate re-push in pre-push hook
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 56s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m9s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m59s
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 14:28:55 +01:00
f514349ccf chore: sync versions to v1.8.2 [skip ci] 2026-02-13 14:27:22 +01:00
a71f86560b chore: fix @mintel/directus-extension-toolkit build and update eslint ignores
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 2m1s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m35s
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 14:21:39 +01:00
de8314732d chore: fix remaining build script syntax errors in extensions
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m5s
Monorepo Pipeline / 🏗️ Build (push) Failing after 2m49s
Monorepo Pipeline / 🧪 Test (push) Successful in 3m0s
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 12:15:30 +01:00
bdf7773310 chore: finalize 'meaningful' sync hook and pipeline stabilization
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (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 / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-13 12:15:01 +01:00
a25e4aa1d4 chore: stabilize pipeline, fix extension build scripts, and finalize version sync hook
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧹 Lint (push) Has started running
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 (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-13 12:14:27 +01:00
ecc2163b8e chore: remove redundant version sync from pre-push hook
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 47s
Monorepo Pipeline / 🏗️ Build (push) Failing after 3m27s
Monorepo Pipeline / 🧪 Test (push) Successful in 4m33s
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 12:08:58 +01:00
af02378d29 chore: sync versions
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 4s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m10s
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 / 🧹 Lint (push) Has been cancelled
2026-02-13 12:05:14 +01:00
f8847a7a10 feat(next-feedback): refine selector filters for tailwind and dynamic classes
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 8s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m59s
Monorepo Pipeline / 🧪 Test (push) Successful in 4m5s
Monorepo Pipeline / 🧹 Lint (push) Successful in 5m8s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 20s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 18s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 20s
Monorepo Pipeline / 🚀 Release (push) Successful in 8m3s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 10m25s
2026-02-13 12:03:33 +01:00
117b23db1e feat(next-feedback): improve selector precision with @medv/finder and fix client/server boundary 2026-02-13 12:03:11 +01:00
d6f9a24823 chore: sync versions to 1.8.0 2026-02-12 22:05:20 +01:00
422e4fccba feat(cloner): add cloner-library and finalize pdf-library rename 2026-02-12 22:04:40 +01:00
57ec4d7544 chore: bump versions 2026-02-12 21:47:55 +01:00
112 changed files with 251221 additions and 1068 deletions

View File

@@ -0,0 +1,82 @@
---
description: How to manage and deploy Directus CMS infrastructure changes.
---
# Directus CMS Infrastructure Workflow
This workflow ensures "Industrial Grade" consistency and stability across local, testing, and production environments for the `at-mintel` Directus CMS.
## 1. Local Development Lifecycle
### Starting the CMS
To start the local Directus instance with extensions:
```bash
cd packages/cms-infra
npm run up
```
### Modifying Schema
1. **Directus UI**: Make your changes directly in the local Directus Admin UI (Collections, Fields, Relations).
2. **Take Snapshot**:
```bash
cd packages/cms-infra
npm run snapshot:local
```
This updates `packages/cms-infra/schema/snapshot.yaml`.
3. **Commit**: Commit the updated `snapshot.yaml`.
## 2. Deploying Schema Changes
### To Local Environment (Reconciliation)
If you pull changes from Git and need to apply them to your local database:
```bash
cd packages/cms-infra
npm run schema:apply:local
```
> [!IMPORTANT]
> This command automatically runs `scripts/cms-reconcile.sh` to prevent "Field already exists" errors by registering database columns in Directus metadata first.
### To Production (Infra)
To deploy the local snapshot to the production server:
```bash
cd packages/cms-infra
npm run schema:apply:infra
```
This script:
1. Syncs built extensions via rsync.
2. Injects the `snapshot.yaml` into the remote container.
3. Runs `directus schema apply`.
4. Restarts Directus to clear the schema cache.
## 3. Data Synchronization
### Pulling from Production
To update your local environment with production data and assets:
```bash
cd packages/cms-infra
npm run sync:pull
```
### Pushing to Production
> [!CAUTION]
> This will overwrite production data. Use with extreme care.
```bash
cd packages/cms-infra
npm run sync:push
```
## 4. Extension Management
When modifying extensions in `packages/*-manager`:
1. Extensions are automatically built and synced when running `npm run up`.
2. To sync manually without restarting the stack:
```bash
cd packages/cms-infra
npm run build:extensions
```
## 5. Troubleshooting "Field already exists"
If `schema:apply` fails with "Field already exists", run:
```bash
./scripts/cms-reconcile.sh
```
This script ensures the database state matches Directus's internal field registry (`directus_fields`).

6
.env
View File

@@ -1,5 +1,5 @@
# Project # Project
IMAGE_TAG=latest 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.7.12 IMAGE_TAG=1.8.4
PROJECT_NAME=sample-website PROJECT_NAME=sample-website
PROJECT_COLOR=#82ed20 PROJECT_COLOR=#82ed20

View File

@@ -1,2 +1,4 @@
**/index.js **/index.js
**/dist/** **/dist/**
packages/cms-infra/extensions/**
packages/cms-infra/extensions/**

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,16 +1,44 @@
# 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, syncing versions..." echo "🏷️ Tag detected: $TAG, ensuring versions are synced..."
# Run sync script
pnpm sync-versions "$TAG" pnpm sync-versions "$TAG"
# Stage the changed files (excluding ignored files like .env) # Check for changes in relevant files
git add package.json packages/*/package.json apps/*/package.json .env.example SYNC_FILES="package.json packages/*/package.json apps/*/package.json .env.example"
CHANGES=$(git status --porcelain $SYNC_FILES)
echo "⚠️ package.json and .env files updated to match tag $TAG." if [[ -n "$CHANGES" ]]; then
echo "⚠️ Note: You might need to push again if these changes were not already in your commit/tag." echo "📝 Version sync made changes. Integrating into tag..."
# Stage and commit
git add $SYNC_FILES
git commit -m "chore: sync versions to $TAG" --no-verify
# Force update the local tag to point to the new commit
git tag -f "$TAG" > /dev/null
echo "✅ Tag $TAG has been updated locally with synced versions."
echo "🚀 Auto-pushing updated tag..."
# Push the updated tag directly (using --no-verify to avoid recursion)
git push origin "$TAG" --force --no-verify
echo "✨ All done! Hook integrated the sync and pushed for you."
exit 1 # Still exit 1 to abort the original (now outdated) push attempt
else
echo "✨ Versions already in sync for $TAG."
fi
fi fi
done done

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.7.12", "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

@@ -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

@@ -1,10 +1,10 @@
{ {
"name": "acquisition", "name": "acquisition",
"version": "1.7.12", "version": "1.8.2",
"type": "module", "type": "module",
"directus:extension": { "directus:extension": {
"type": "endpoint", "type": "endpoint",
"path": "dist/index.js", "path": "index.js",
"source": "src/index.ts", "source": "src/index.ts",
"host": "^11.0.0" "host": "^11.0.0"
}, },
@@ -14,7 +14,7 @@
}, },
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/acquisition": "workspace:*", "@mintel/pdf": "workspace:*",
"@mintel/mail": "workspace:*", "@mintel/mail": "workspace:*",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"

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,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 @@
Qy-qP

View File

@@ -5,7 +5,7 @@ export default [
{ {
ignores: [ ignores: [
"packages/cms-infra/extensions/**", "packages/cms-infra/extensions/**",
"packages/customer-manager/index.js", "**/index.js",
"**/*.db", "**/*.db",
"**/build/**", "**/build/**",
"**/data/**", "**/data/**",

View File

@@ -15,6 +15,9 @@
"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": "cd packages/cms-infra && npm run up -- --force-recreate",
"cms:down": "cd packages/cms-infra && npm run down",
"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",
"release": "pnpm build && changeset publish", "release": "pnpm build && changeset publish",
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public", "release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
@@ -26,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",
@@ -33,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",
@@ -47,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.7.12", "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,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.4",
"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": "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)"
> >
@@ -23,164 +31,142 @@
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" /> <v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
</v-list-item-icon> </v-list-item-icon>
<v-list-item-content> <v-list-item-content>
<v-text-overflow :text="lead.company_name" /> <v-text-overflow :text="getCompanyName(lead)" />
</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="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">{{ selectedLead.company_name }}</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">Firma</span> <span class="label">Organisation / Firma (Zentral)</span>
<v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus /> <MintelSelect
v-model="newLead.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
allow-add
@add="openQuickAdd('company')"
/>
</div> </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>
@@ -190,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[]>([]);
@@ -203,22 +190,28 @@ 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: '', company: null,
website_url: '', website_url: '',
contact_name: '',
contact_email: '',
contact_person: null, contact_person: null,
briefing: '', briefing: '',
status: 'new' status: 'new'
}); });
const companies = ref<any[]>([]);
const people = ref<any[]>([]); const people = ref<any[]>([]);
const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
const peopleOptions = computed(() => const peopleOptions = computed(() =>
people.value.map(p => ({ people.value.map(p => ({
text: `${p.first_name} ${p.last_name}`, text: `${p.first_name} ${p.last_name}`,
@@ -226,36 +219,70 @@ const peopleOptions = computed(() =>
})) }))
); );
function getPersonName(id: string) { function getCompanyName(lead: any) {
if (!lead) return '';
if (lead.company) {
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || 'Unbekannte Firma');
}
return 'Unbekannte Organisation';
}
function getPersonName(id: string | any) {
if (!id) return '';
if (typeof id === 'object') return `${id.first_name} ${id.last_name}`;
const person = people.value.find(p => p.id === id); const person = people.value.find(p => p.id === id);
return person ? `${person.first_name} ${person.last_name}` : id; return person ? `${person.first_name} ${person.last_name}` : id;
} }
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(fetchLeads); async function fetchData() {
try {
const [leadsResp, peopleResp, companiesResp] = await Promise.all([
api.get('/items/leads', {
params: {
sort: '-date_created',
fields: '*.*'
}
}),
api.get('/items/people', { params: { sort: 'last_name' } }),
api.get('/items/companies', { params: { sort: 'name' } })
]);
leads.value = leadsResp.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;
}
} catch (e: any) {
console.error('Fetch error:', e);
}
}
async function fetchLeads() { async function fetchLeads() {
const [leadsResp, peopleResp] = await Promise.all([ await fetchData();
api.get('/items/leads', { params: { sort: '-date_created' } }),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
leads.value = leadsResp.data.data;
people.value = peopleResp.data.data;
if (!selectedLeadId.value && leads.value.length > 0) {
selectedLeadId.value = leads.value[0].id;
}
} }
function selectLead(id: string) { 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;
@@ -318,7 +345,10 @@ function openPdf() {
} }
async function saveLead() { async function saveLead() {
if (!newLead.value.company_name) return; if (!newLead.value.company) {
notice.value = { type: 'danger', message: 'Organisation erforderlich.' };
return;
}
savingLead.value = true; savingLead.value = true;
try { try {
const payload = { const payload = {
@@ -327,18 +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: '',
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 {
@@ -346,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';
@@ -365,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; }
@@ -389,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; }
@@ -398,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.7.12", "version": "1.8.4",
"type": "module", "type": "module",
"directus:extension": { "directus:extension": {
"type": "endpoint", "type": "endpoint",
@@ -14,7 +14,7 @@
}, },
"devDependencies": { "devDependencies": {
"@directus/extensions-sdk": "11.0.2", "@directus/extensions-sdk": "11.0.2",
"@mintel/acquisition": "workspace:*", "@mintel/pdf": "workspace:*",
"@mintel/mail": "workspace:*", "@mintel/mail": "workspace:*",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"typescript": "^5.6.3" "typescript": "^5.6.3"
@@ -24,4 +24,4 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
} }
} }

View File

@@ -1,5 +1,5 @@
import { defineEndpoint } from "@directus/extensions-sdk"; import { defineEndpoint } from "@directus/extensions-sdk";
import { AcquisitionService, PdfEngine } from "@mintel/acquisition"; import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail"; import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
import { createElement } from "react"; import { createElement } from "react";
import * as path from "path"; import * as path from "path";
@@ -39,22 +39,25 @@ export default defineEndpoint((router, { services, env }) => {
router.post("/audit-email/:id", async (req: any, res: any) => { router.post("/audit-email/:id", async (req: any, res: any) => {
const { id } = req.params; const { id } = req.params;
const { ItemsService, MailService } = services;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability }); const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability }); const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability }); const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try { try {
const lead = await leadsService.readOne(id); const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" }); if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
let recipientEmail = lead.contact_email; let recipientEmail = lead.contact_email;
let companyName = lead.company_name; let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) { if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person); recipientEmail = lead.contact_person.email || recipientEmail;
if (person && person.email) {
recipientEmail = person.email; if (lead.contact_person.company) {
companyName = person.company || lead.company_name; const personCompany = await companiesService.readOne(lead.contact_person.company);
companyName = personCompany?.name || companyName;
} }
} }
@@ -119,20 +122,22 @@ export default defineEndpoint((router, { services, env }) => {
const { id } = req.params; const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability }); const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability }); const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability }); const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try { try {
const lead = await leadsService.readOne(id); const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" }); if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
let recipientEmail = lead.contact_email; let recipientEmail = lead.contact_email;
let companyName = lead.company_name; let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) { if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person); recipientEmail = lead.contact_person.email || recipientEmail;
if (person && person.email) {
recipientEmail = person.email; if (lead.contact_person.company) {
companyName = person.company || lead.company_name; const personCompany = await companiesService.readOne(lead.contact_person.company);
companyName = personCompany?.name || companyName;
} }
} }

View File

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

View File

@@ -0,0 +1,41 @@
import { build } from 'esbuild';
import { resolve, dirname } from 'path';
import { mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const entryPoints = [
resolve(__dirname, 'src/index.ts')
];
try {
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
} catch (e) { }
console.log(`Building entry point...`);
build({
entryPoints: entryPoints,
bundle: true,
platform: 'node',
target: 'node18',
outdir: resolve(__dirname, 'dist'),
format: 'esm',
loader: {
'.ts': 'ts',
'.js': 'js',
},
external: ["playwright", "crawlee", "axios", "cheerio", "fs", "path", "os", "http", "https", "url", "stream", "util", "child_process"],
}).then(() => {
console.log("Build succeeded!");
}).catch((e) => {
if (e.errors) {
console.error("Build failed with errors:");
e.errors.forEach(err => console.error(` ${err.text} at ${err.location?.file}:${err.location?.line}`));
} else {
console.error("Build failed:", e);
}
process.exit(1);
});

View File

@@ -0,0 +1,30 @@
{
"name": "@mintel/cloner",
"version": "1.8.4",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs --watch"
},
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.6.3",
"@types/node": "^22.0.0"
},
"dependencies": {
"playwright": "^1.40.0",
"crawlee": "^3.7.0",
"axios": "^1.6.0",
"cheerio": "^1.0.0-rc.12"
}
}

View File

@@ -0,0 +1,93 @@
import axios from "axios";
import fs from "node:fs";
import path from "node:path";
export interface AssetMap {
[originalUrl: string]: string;
}
export class AssetManager {
private userAgent: string;
constructor(userAgent: string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36") {
this.userAgent = userAgent;
}
public sanitizePath(rawPath: string): string {
return rawPath
.split("/")
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
.join("/");
}
public async downloadFile(url: string, assetsDir: string): Promise<string | null> {
if (url.startsWith("//")) url = `https:${url}`;
if (!url.startsWith("http")) return null;
try {
const u = new URL(url);
const relPath = this.sanitizePath(u.hostname + u.pathname);
const dest = path.join(assetsDir, relPath);
if (fs.existsSync(dest)) return `./assets/${relPath}`;
const res = await axios.get(url, {
responseType: "arraybuffer",
headers: { "User-Agent": this.userAgent },
timeout: 15000,
validateStatus: () => true,
});
if (res.status !== 200) return null;
if (!fs.existsSync(path.dirname(dest)))
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(res.data));
return `./assets/${relPath}`;
} catch {
return null;
}
}
public async processCssRecursively(
cssContent: string,
cssUrl: string,
assetsDir: string,
urlMap: AssetMap,
depth = 0,
): Promise<string> {
if (depth > 5) return cssContent;
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"'\)]+)["']?\)?/gi;
let match;
let newContent = cssContent;
while ((match = urlRegex.exec(cssContent)) !== null) {
const originalUrl = match[1];
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
continue;
try {
const absUrl = new URL(originalUrl, cssUrl).href;
const local = await this.downloadFile(absUrl, assetsDir);
if (local) {
const u = new URL(cssUrl);
const cssPath = u.hostname + u.pathname;
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
const rel = path.relative(
path.dirname(this.sanitizePath(cssPath)),
this.sanitizePath(assetPath),
);
newContent = newContent.split(originalUrl).join(rel);
urlMap[absUrl] = local;
}
} catch {
// Ignore
}
}
return newContent;
}
}

View File

@@ -0,0 +1,184 @@
import { chromium, Browser, BrowserContext, Page } from "playwright";
import fs from "node:fs";
import path from "node:path";
import axios from "axios";
import { AssetManager, AssetMap } from "./AssetManager.js";
export interface PageClonerOptions {
outputDir: string;
userAgent?: string;
}
export class PageCloner {
private options: PageClonerOptions;
private assetManager: AssetManager;
private userAgent: string;
constructor(options: PageClonerOptions) {
this.options = options;
this.userAgent = options.userAgent || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
this.assetManager = new AssetManager(this.userAgent);
}
public async clone(targetUrl: string): Promise<string> {
const urlObj = new URL(targetUrl);
const domainSlug = urlObj.hostname.replace("www.", "");
const domainDir = path.resolve(this.options.outputDir, domainSlug);
const assetsDir = path.join(domainDir, "assets");
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
if (!pageSlug) pageSlug = "index";
const htmlFilename = `${pageSlug}.html`;
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: this.userAgent,
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
const urlMap: AssetMap = {};
const foundAssets = new Set<string>();
page.on("response", (response) => {
if (response.status() === 200) {
const url = response.url();
if (url.match(/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i)) {
foundAssets.add(url);
}
}
});
try {
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
// Scroll Wave
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve(true);
}
}, 100);
});
});
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
await page.waitForTimeout(3000);
// Sanitization
await page.evaluate(() => {
const assetPattern = /\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
document.querySelectorAll("*").forEach((el) => {
if (["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(el.tagName)) return;
const htmlEl = el as HTMLElement;
const style = window.getComputedStyle(htmlEl);
if (style.opacity === "0" || style.visibility === "hidden") {
htmlEl.style.setProperty("opacity", "1", "important");
htmlEl.style.setProperty("visibility", "visible", "important");
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const val = attr.value;
if (assetPattern.test(val) || name.includes("src") || name.includes("image")) {
if (el.tagName === "IMG") {
const img = el as HTMLImageElement;
if (name.includes("srcset")) img.srcset = val;
else if (!img.src || img.src.includes("data:")) img.src = val;
}
if (el.tagName === "SOURCE") (el as HTMLSourceElement).srcset = val;
if (el.tagName === "VIDEO" || el.tagName === "AUDIO") (el as HTMLMediaElement).src = val;
if (val.match(/^(https?:\/\/|\/\/|\/)/) && !name.includes("href")) {
const bg = htmlEl.style.backgroundImage;
if (!bg || bg === "none") htmlEl.style.backgroundImage = `url('${val}')`;
}
}
}
});
if (document.body) {
document.body.style.setProperty("opacity", "1", "important");
document.body.style.setProperty("visibility", "visible", "important");
}
});
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1000);
let content = await page.content();
const regexPatterns = [
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
/url\(["']?([^"'\)]+)["']?\)/gi,
];
for (const pattern of regexPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
try { foundAssets.add(new URL(match[1], targetUrl).href); } catch { }
}
}
for (const url of foundAssets) {
const local = await this.assetManager.downloadFile(url, assetsDir);
if (local) {
urlMap[url] = local;
const clean = url.split("?")[0];
urlMap[clean] = local;
if (clean.endsWith(".css")) {
try {
const { data } = await axios.get(url, { headers: { "User-Agent": this.userAgent } });
const processedCss = await this.assetManager.processCssRecursively(data, url, assetsDir, urlMap);
const relPath = this.assetManager.sanitizePath(new URL(url).hostname + new URL(url).pathname);
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
} catch { }
}
}
}
let finalContent = content;
const sortedUrls = Object.keys(urlMap).sort((a, b) => b.length - a.length);
if (sortedUrls.length > 0) {
const escaped = sortedUrls.map((u) => u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
finalContent = finalContent.replace(masterRegex, (match) => urlMap[match] || match);
}
const commonDirs = ["/wp-content/", "/wp-includes/", "/assets/", "/static/", "/images/"];
for (const dir of commonDirs) {
const localDir = `./assets/${urlObj.hostname}${dir}`;
finalContent = finalContent.split(`"${dir}`).join(`"${localDir}`).split(`'${dir}`).join(`'${localDir}`).split(`(${dir}`).join(`(${localDir}`);
}
const domainPattern = new RegExp(`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`, "gi");
finalContent = finalContent.replace(domainPattern, () => "./");
finalContent = finalContent.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (match, scriptContent) => {
const lower = scriptContent.toLowerCase();
return (lower.includes("google-analytics") || lower.includes("gtag") || lower.includes("fbq") || lower.includes("lazy") || lower.includes("tracker")) ? "" : match;
});
const headEnd = finalContent.indexOf("</head>");
if (headEnd > -1) {
const stabilityCss = `\n<style>* { transition: none !important; animation: none !important; scroll-behavior: auto !important; } [data-aos], .reveal, .lazypath, .lazy-load, [data-src] { opacity: 1 !important; visibility: visible !important; transform: none !important; clip-path: none !important; } img, video, iframe { max-width: 100%; display: block; } a { pointer-events: none; cursor: default; } </style>`;
finalContent = finalContent.slice(0, headEnd) + stabilityCss + finalContent.slice(headEnd);
}
const finalPath = path.join(domainDir, htmlFilename);
fs.writeFileSync(finalPath, finalContent);
return finalPath;
} finally {
await browser.close();
}
}
}

View File

@@ -0,0 +1,123 @@
import { PlaywrightCrawler, RequestQueue } from 'crawlee';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { execSync } from 'node:child_process';
export interface WebsiteClonerOptions {
baseOutputDir: string;
maxRequestsPerCrawl?: number;
maxConcurrency?: number;
}
export class WebsiteCloner {
private options: WebsiteClonerOptions;
constructor(options: WebsiteClonerOptions) {
this.options = {
maxRequestsPerCrawl: 100,
maxConcurrency: 3,
...options
};
}
public async clone(targetUrl: string, outputDirName?: string): Promise<string> {
const urlObj = new URL(targetUrl);
const domain = urlObj.hostname;
const finalOutputDirName = outputDirName || domain.replace(/\./g, '-');
const baseOutputDir = path.resolve(this.options.baseOutputDir, finalOutputDirName);
if (fs.existsSync(baseOutputDir)) {
fs.rmSync(baseOutputDir, { recursive: true, force: true });
}
fs.mkdirSync(baseOutputDir, { recursive: true });
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
console.log(`📂 Output: ${baseOutputDir}`);
const requestQueue = await RequestQueue.open();
await requestQueue.addRequest({ url: targetUrl });
const crawler = new PlaywrightCrawler({
requestQueue,
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
maxConcurrency: this.options.maxConcurrency,
async requestHandler({ request, enqueueLinks, log }) {
const url = request.url;
log.info(`Capturing ${url}...`);
const u = new URL(url);
let relPath = u.pathname;
if (relPath === '/' || relPath === '') relPath = '/index.html';
if (!relPath.endsWith('.html') && !path.extname(relPath)) relPath += '/index.html';
if (relPath.startsWith('/')) relPath = relPath.substring(1);
const fullPath = path.join(baseOutputDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
try {
// Note: This assumes single-file-cli is available in the environment
execSync(`npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`, {
stdio: 'inherit'
});
} catch (e) {
log.error(`Failed to capture ${url} with SingleFile`);
}
await enqueueLinks({
strategy: 'same-domain',
transformRequestFunction: (req) => {
if (/\.(download|pdf|zip|gz|exe|png|jpg|jpeg|gif|svg|css|js)$/i.test(req.url)) return false;
return req;
}
});
},
});
await crawler.run();
console.log('🔗 Rewriting internal links for offline navigation...');
const allFiles = this.getFiles(baseOutputDir).filter(f => f.endsWith('.html'));
for (const file of allFiles) {
let content = fs.readFileSync(file, 'utf8');
const fileRelToRoot = path.relative(baseOutputDir, file);
content = content.replace(/href="([^"]+)"/g, (match, href) => {
if (href.startsWith(targetUrl) || href.startsWith('/') || (!href.includes('://') && !href.startsWith('data:'))) {
try {
const linkUrl = new URL(href, targetUrl);
if (linkUrl.hostname === domain) {
let linkPath = linkUrl.pathname;
if (linkPath === '/' || linkPath === '') linkPath = '/index.html';
if (!linkPath.endsWith('.html') && !path.extname(linkPath)) linkPath += '/index.html';
if (linkPath.startsWith('/')) linkPath = linkPath.substring(1);
const relativeLink = path.relative(path.dirname(fileRelToRoot), linkPath);
return `href="${relativeLink}"`;
}
} catch (e) { }
}
return match;
});
fs.writeFileSync(file, content);
}
console.log(`\n✅ Done! Perfect clone complete in: ${baseOutputDir}`);
return baseOutputDir;
}
private getFiles(dir: string, fileList: string[] = []) {
const files = fs.readdirSync(dir);
for (const file of files) {
const name = path.join(dir, file);
if (fs.statSync(name).isDirectory()) {
this.getFiles(name, fileList);
} else {
fileList.push(name);
}
}
return fileList;
}
}

View File

@@ -0,0 +1,3 @@
export * from "./AssetManager.js";
export * from "./PageCloner.js";
export * from "./WebsiteCloner.js";

View File

@@ -0,0 +1,17 @@
{
"extends": "../tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": false
},
"include": [
"src/**/*"
]
}

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,12 +1,17 @@
{ {
"name": "@mintel/cms-infra", "name": "@mintel/cms-infra",
"version": "1.7.12", "version": "1.8.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"up": "npm run build:extensions && docker compose up -d", "up": "npm run build:extensions && docker compose up -d",
"down": "docker compose down", "down": "docker compose down",
"logs": "docker compose logs -f", "logs": "docker compose logs -f",
"build:extensions": "../../scripts/sync-extensions.sh" "build:extensions": "../../scripts/sync-extensions.sh",
"schema:apply:local": "../../scripts/cms-apply.sh local",
"schema:apply:infra": "../../scripts/cms-apply.sh infra",
"snapshot:local": "../../scripts/cms-snapshot.sh",
"sync:push": "../../scripts/sync-directus.sh push infra",
"sync:pull": "../../scripts/sync-directus.sh pull infra"
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,28 @@
{
"name": "company-manager",
"description": "Custom High-Fidelity Management for Directus",
"icon": "extension",
"version": "1.8.4",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "app",
"name": "company manager"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/directus-extension-toolkit": "workspace:*",
"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,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.4",
"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": "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,152 +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>
<v-divider v-if="isEditingEmployee" /> <div class="field">
<span class="label">Zentrale Person (Verknüpfung)</span>
<v-select
v-model="userForm.person"
:items="peopleOptions"
placeholder="Master-Person auswählen..."
/>
</div>
<div v-if="isEditingEmployee" class="field"> <v-divider v-if="isEditingUser" />
<span class="label">Temporäres Passwort</span>
<v-input v-model="employeeForm.temporary_password" readonly class="password-input" /> <div v-if="isEditingUser" class="field">
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p> <span class="label">Temporäres Passwort</span>
</div> <v-input v-model="userForm.temporary_password" readonly class="password-input" />
</div> <p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
</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>
@@ -154,37 +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 } 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: '' });
temporary_password: ''
});
const tableHeaders = [ const tableHeaders = [
{ text: 'Name', value: 'name', sortable: true }, { text: 'Name', value: 'name', sortable: true },
@@ -192,169 +228,159 @@ const tableHeaders = [
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true } { text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
]; ];
async function fetchCompanies() { const companyOptions = computed(() => companies.value.map(c => ({ text: c.name, value: c.id })));
const res = await api.get('/items/companies', { const peopleOptions = computed(() => people.value.map(p => ({ text: `${p.first_name} ${p.last_name} (${p.email})`, value: p.id })));
params: {
fields: ['id', 'name'], async function fetchData() {
sort: 'name', loading.value = true;
}, try {
}); const [custResp, compResp, peopleResp] = await Promise.all([
companies.value = res.data.data; api.get('/items/customers', { params: { fields: ['*', 'company.*', 'contact_person.*'], sort: 'company.name' } }),
api.get('/items/companies', { params: { sort: 'name' } }),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
items.value = custResp.data.data;
companies.value = compResp.data.data;
people.value = peopleResp.data.data;
} finally {
loading.value = false;
}
} }
async function 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: ['*'], 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 || '',
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
});
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
} else {
await api.post('/items/client_users', {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email,
company: selectedCompany.value.id
});
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
}
drawerEmployeeActive.value = false;
await selectCompany(selectedCompany.value);
} catch (e: any) {
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);
fetchCompanies();
});
</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); }
@@ -364,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;
@@ -373,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

@@ -0,0 +1,31 @@
{
"name": "@mintel/directus-extension-toolkit",
"version": "1.8.4",
"description": "Shared toolkit for Directus extensions in the Mintel ecosystem",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"files": [
"dist"
],
"exports": {
".": {
"import": "./dist/index.js"
}
},
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"vue": "^3.4.0"
},
"peerDependencies": {
"@directus/extensions-sdk": "*",
"vue": "^3.4.0"
}
}

View File

@@ -0,0 +1,102 @@
<template>
<private-view :title="title">
<template #navigation>
<slot name="navigation" />
</template>
<template #title-outer:after>
<v-notice v-if="notice" :type="notice.type" @close="$emit('close-notice')" dismissible>
{{ notice.message }}
</v-notice>
</template>
<div class="mintel-manager-layout">
<div v-if="isEmpty" class="empty-state">
<v-info :title="emptyTitle" :icon="emptyIcon" center>
<slot name="empty-state" />
</v-info>
</div>
<template v-else>
<header class="mintel-header">
<div class="header-left">
<h1 class="mintel-title">{{ itemTitle }}</h1>
<p class="mintel-subtitle">
<slot name="subtitle" />
</p>
</div>
<div class="header-right">
<slot name="actions" />
</div>
</header>
<v-divider />
<div class="mintel-content">
<slot />
</div>
</template>
</div>
</private-view>
</template>
<script setup lang="ts">
defineProps<{
title: string;
itemTitle?: string;
isEmpty?: boolean;
emptyTitle?: string;
emptyIcon?: string;
notice?: { type: string; message: string } | null;
}>();
defineEmits(['close-notice']);
</script>
<style scoped>
.mintel-manager-layout {
padding: 32px;
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.mintel-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.mintel-title {
font-size: 24px;
font-weight: 800;
margin-bottom: 4px;
color: var(--theme--foreground);
}
.mintel-subtitle {
color: var(--theme--foreground-subdued);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.header-right {
display: flex;
gap: 12px;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.mintel-content {
margin-top: 32px;
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<div class="mintel-select">
<v-select
:model-value="modelValue"
:items="items"
:placeholder="placeholder"
:searchable="searchable"
:show-deselect="showDeselect"
@update:model-value="$emit('update:modelValue', $event)"
/>
<v-button v-if="allowAdd" secondary rounded icon x-small class="add-button" @click="$emit('add')">
<v-icon name="add" />
</v-button>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: [String, Number],
default: null
},
items: {
type: Array as () => Array<{ text: string; value: string | number }>,
required: true
},
placeholder: {
type: String,
default: 'Auswählen...'
},
searchable: {
type: Boolean,
default: true
},
showDeselect: {
type: Boolean,
default: true
},
allowAdd: {
type: Boolean,
default: false
}
});
defineEmits(['update:modelValue', 'add']);
</script>
<style scoped>
.mintel-select {
display: flex;
align-items: center;
gap: 8px;
}
.mintel-select :deep(.v-select) {
flex: 1;
}
.add-button {
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="mintel-stat-card" @click="$emit('click')">
<div class="stat-icon">
<v-icon :name="icon" large />
</div>
<div class="stat-content">
<span class="stat-label">{{ label }}</span>
<span class="stat-value">{{ value }}</span>
</div>
<v-icon name="chevron_right" class="arrow" />
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string;
value: string | number;
icon: string;
}>();
defineEmits(['click']);
</script>
<style scoped>
.mintel-stat-card {
background: var(--theme--background-normal);
border: 1px solid var(--theme--border);
padding: 24px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 20px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.mintel-stat-card:hover {
border-color: var(--theme--primary);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
}
.stat-icon {
width: 56px;
height: 56px;
background: var(--theme--background-subdued);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme--primary);
}
.stat-content {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--theme--foreground-subdued);
letter-spacing: 0.5px;
}
.stat-value {
font-size: 28px;
font-weight: 800;
color: var(--theme--foreground);
}
.arrow {
position: absolute;
right: 24px;
opacity: 0.2;
}
.mintel-stat-card:hover .arrow {
opacity: 1;
color: var(--theme--primary);
}
</style>

View File

@@ -0,0 +1,3 @@
export { default as MintelSelect } from './MintelSelect.vue';
export { default as MintelManagerLayout } from './MintelManagerLayout.vue';
export { default as MintelStatCard } from './MintelStatCard.vue';

View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: resolve('src/index.ts'),
name: 'MintelDirectusToolkit',
fileName: 'index',
formats: ['es']
},
rollupOptions: {
external: ['vue', '@directus/extensions-sdk'],
output: {
globals: {
vue: 'Vue',
'@directus/extensions-sdk': 'DirectusExtensionsSDK'
}
}
}
}
});

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.7.12", "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,26 +1,23 @@
{ {
"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.4",
"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": "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

@@ -78,7 +78,7 @@
<div class="card-text">{{ item.text }}</div> <div class="card-text">{{ item.text }}</div>
<footer class="card-footer"> <footer class="card-footer">
<div class="meta-tags"> <div class="meta-tags">
<v-chip x-small outline>{{ item.project }}</v-chip> <v-chip x-small outline>{{ item.company?.name || item.project }}</v-chip>
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small /> <v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
</div> </div>
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small /> <v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
@@ -142,7 +142,8 @@
<TransitionGroup name="thread-list"> <TransitionGroup name="thread-list">
<div v-for="reply in comments" :key="reply.id" class="reply-bubble"> <div v-for="reply in comments" :key="reply.id" class="reply-bubble">
<header class="reply-header"> <header class="reply-header">
<span class="reply-user">{{ reply.user_name }}</span> <span class="reply-user">{{ reply.user_name || 'System' }}</span>
<span v-if="reply.person" class="reply-person">({{ reply.person.first_name }} {{ reply.person.last_name }})</span>
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span> <span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
</header> </header>
<div class="reply-text">{{ reply.text }}</div> <div class="reply-text">{{ reply.text }}</div>
@@ -168,8 +169,12 @@
<v-card-title>Context</v-card-title> <v-card-title>Context</v-card-title>
<v-card-text class="meta-list"> <v-card-text class="meta-list">
<div class="meta-item"> <div class="meta-item">
<label><v-icon name="public" x-small /> Website</label> <label><v-icon name="business" x-small /> Organisation / Firma</label>
<strong>{{ selectedItem.project }}</strong> <strong>{{ selectedItem.company?.name || selectedItem.project }}</strong>
</div>
<div v-if="selectedItem.person" class="meta-item">
<label><v-icon name="person" x-small /> Zentrale Person</label>
<strong>{{ selectedItem.person.first_name }} {{ selectedItem.person.last_name }}</strong>
</div> </div>
<div class="meta-item"> <div class="meta-item">
<label><v-icon name="link" x-small /> Source Path</label> <label><v-icon name="link" x-small /> Source Path</label>
@@ -238,13 +243,14 @@ const statusOptions = [
]; ];
const projects = computed(() => { const projects = computed(() => {
const projSet = new Set(items.value.map(i => i.project).filter(Boolean)); const projSet = new Set(items.value.map(i => i.company?.name || i.project).filter(Boolean));
return Array.from(projSet).sort(); return Array.from(projSet).sort();
}); });
const filteredItems = computed(() => { const filteredItems = computed(() => {
return items.value.filter(item => { return items.value.filter(item => {
const matchProject = currentProject.value === 'all' || item.project === currentProject.value; const projectName = item.company?.name || item.project;
const matchProject = currentProject.value === 'all' || projectName === currentProject.value;
const status = item.status || 'open'; const status = item.status || 'open';
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value; const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
return matchProject && matchStatus; return matchProject && matchStatus;
@@ -258,7 +264,8 @@ async function fetchData() {
const response = await api.get('/items/visual_feedback', { const response = await api.get('/items/visual_feedback', {
params: { params: {
sort: '-date_created,-id', sort: '-date_created,-id',
limit: 300 limit: 300,
fields: ['*', 'company.*', 'person.*']
} }
}); });
items.value = response.data.data; items.value = response.data.data;
@@ -278,7 +285,8 @@ async function selectItem(item) {
const response = await api.get('/items/visual_feedback_comments', { const response = await api.get('/items/visual_feedback_comments', {
params: { params: {
filter: { feedback_id: { _eq: item.id } }, filter: { feedback_id: { _eq: item.id } },
sort: '-date_created,-id' sort: '-date_created,-id',
fields: ['*', 'person.*']
} }
}); });
comments.value = response.data.data; comments.value = response.data.data;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/gatekeeper", "name": "@mintel/gatekeeper",
"version": "1.7.12", "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.7.12", "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.7.12", "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.7.12", "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.7.12", "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.7.12", "version": "1.8.4",
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://npm.infra.mintel.me" "registry": "https://npm.infra.mintel.me"
@@ -27,6 +27,7 @@
}, },
"dependencies": { "dependencies": {
"@directus/sdk": "^21.0.0", "@directus/sdk": "^21.0.0",
"@medv/finder": "^4.0.2",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.5.4", "framer-motion": "^11.5.4",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",

View File

@@ -6,6 +6,7 @@ import { MessageSquare, X, Check, Plus, List, Send, User } from "lucide-react";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import html2canvas from "html2canvas"; import html2canvas from "html2canvas";
import { finder } from "@medv/finder";
function cn(...inputs: any[]) { function cn(...inputs: any[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@@ -30,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,
); );
@@ -107,15 +120,16 @@ export function FeedbackOverlay() {
}, []); }, []);
const getSelector = (el: HTMLElement): string => { const getSelector = (el: HTMLElement): string => {
if (el.id) return `#${el.id}`; return finder(el, {
const path = []; root: document.body,
let curr: HTMLElement | null = el; className: (name) =>
while (curr && curr.parentElement) { !name.startsWith('record-mode-') &&
const index = Array.from(curr.parentElement.children).indexOf(curr) + 1; !name.startsWith('feedback-') &&
path.unshift(`${curr.tagName.toLowerCase()}:nth-child(${index})`); !name.includes('[') &&
curr = curr.parentElement; !name.includes('/') &&
} !name.match(/^[a-z]-[0-9]/),
return path.join(" > "); idName: (name) => !name.startsWith('__next') && !name.includes(':'),
});
}; };
useEffect(() => { useEffect(() => {

View File

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

View File

@@ -6,7 +6,4 @@ export default defineConfig({
dts: true, dts: true,
clean: true, clean: true,
sourcemap: true, sourcemap: true,
banner: {
js: "'use client';",
},
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mintel/next-observability", "name": "@mintel/next-observability",
"version": "1.7.12", "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.7.12", "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.7.12", "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.7.12", "version": "1.8.4",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
@@ -35,4 +35,4 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
} }
} }

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,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.4",
"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": "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</span>
<p class="value">{{ selectedPerson.company || '---' }}</p>
</div>
<div class="detail-item">
<span class="label">Telefon</span>
<p class="value">{{ selectedPerson.phone || '---' }}</p>
</div>
</div>
</div> </div>
</div> </div>
@@ -95,15 +98,17 @@
</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">Organisation / Firma</span> <span class="label">Zentrale Firma</span>
<v-input v-model="form.company" placeholder="z.B. Mintel" /> <MintelSelect
</div> v-model="form.company"
<div class="field"> :items="companyOptions"
<span class="label">Telefon</span> placeholder="Bestehende Firma auswählen..."
<v-input v-model="form.phone" placeholder="+49 ..." /> allow-add
@add="openQuickAdd('company')"
/>
</div> </div>
</div> </div>
@@ -115,15 +120,17 @@
</div> </div>
</template> </template>
</v-drawer> </v-drawer>
</private-view> </MintelManagerLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } 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([]);
const companies = ref([]);
const selectedPerson = ref(null); const selectedPerson = ref(null);
const feedback = ref(null); const feedback = ref(null);
const saving = ref(false); const saving = ref(false);
@@ -135,18 +142,41 @@ const form = ref({
first_name: '', first_name: '',
last_name: '', last_name: '',
email: '', email: '',
company: '', company: null
phone: ''
}); });
async function fetchPeople() { const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
function getCompanyName(person: any) {
if (!person) return '---';
if (person.company) {
return typeof person.company === 'object' ? person.company.name : (companies.value.find(c => c.id === person.company)?.name || 'Unbekannte Firma');
}
return '---';
}
async function fetchData() {
try { try {
const response = await api.get('/items/people', { const [peopleResp, companiesResp] = await Promise.all([
params: { sort: 'last_name' } api.get('/items/people', {
}); params: {
people.ref = response.data.data; sort: 'last_name',
fields: '*.*'
}
}),
api.get('/items/companies', {
params: { sort: 'name' }
})
]);
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
} catch (error) { } catch (error) {
console.error('Failed to fetch people:', error); console.error('Failed to fetch data:', error);
} }
} }
@@ -156,13 +186,26 @@ function selectPerson(person) {
function openCreateDrawer() { function openCreateDrawer() {
isEditing.value = false; isEditing.value = false;
form.value = { id: null, first_name: '', last_name: '', email: '', company: '', phone: '' }; form.value = {
id: null,
first_name: '',
last_name: '',
email: '',
company: null
};
drawerActive.value = true; drawerActive.value = true;
} }
function openEditDrawer() { function openEditDrawer() {
isEditing.value = true; isEditing.value = true;
form.value = { ...selectedPerson.value }; const person = selectedPerson.value;
form.value = {
id: person.id,
first_name: person.first_name,
last_name: person.last_name,
email: person.email,
company: person.company?.id || person.company
};
drawerActive.value = true; drawerActive.value = true;
} }
@@ -174,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 fetchPeople(); await fetchData();
if (isEditing.value) { if (updatedItem) {
selectedPerson.value = form.value; 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 };
@@ -200,56 +246,24 @@ async function deletePerson() {
await api.delete(`/items/people/${selectedPerson.value.id}`); await api.delete(`/items/people/${selectedPerson.value.id}`);
feedback.value = { type: 'success', message: 'Person gelöscht.' }; feedback.value = { type: 'success', message: 'Person gelöscht.' };
selectedPerson.value = null; selectedPerson.value = null;
await fetchPeople(); await fetchData();
} catch (error) { } catch (error) {
feedback.value = { type: 'danger', message: error.message }; feedback.value = { type: 'danger', message: error.message };
} }
} }
onMounted(fetchPeople); function openQuickAdd(type: string) {
feedback.value = { type: 'info', message: `Firma im Company Manager anlegen.` };
}
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 {

Some files were not shown because too many files have changed in this diff Show More