Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ea958655 | |||
| f2035d79dd | |||
| f514349ccf | |||
| a71f86560b | |||
| de8314732d | |||
| bdf7773310 | |||
| a25e4aa1d4 | |||
| ecc2163b8e | |||
| af02378d29 | |||
| f8847a7a10 | |||
| 117b23db1e | |||
| d6f9a24823 | |||
| 422e4fccba | |||
| 57ec4d7544 | |||
| a4d021c658 | |||
| 269d19bbef | |||
| 30ff08c66d | |||
| 81deaf447f | |||
| a0ebc58d6d | |||
| 7498c24c9a | |||
| efba82337c | |||
| c083b309fb | |||
| eb8bf60408 | |||
| a3819490ac | |||
| 1127954fea | |||
| fa0b133012 | |||
| 1b40baebd4 | |||
| 316c03869a | |||
| 63d2acfab5 | |||
| bdeae0aca6 | |||
| 47c70a16f1 | |||
| b96d44bf6d | |||
| 73b60f14a9 |
82
.agent/workflows/cms-workflow.md
Normal file
82
.agent/workflows/cms-workflow.md
Normal 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`).
|
||||||
7
.changeset/resilient-build-scripts.md
Normal file
7
.changeset/resilient-build-scripts.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@mintel/monorepo": patch
|
||||||
|
"acquisition-manager": patch
|
||||||
|
"feedback-commander": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix: make directus extension build scripts more resilient
|
||||||
36
.env
Normal file
36
.env
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Project
|
||||||
|
IMAGE_TAG=v1.8.2
|
||||||
|
PROJECT_NAME=at-mintel
|
||||||
|
PROJECT_COLOR=#82ed20
|
||||||
|
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
GATEKEEPER_PASSWORD=mintel
|
||||||
|
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||||
|
|
||||||
|
# Host Config (Local)
|
||||||
|
TRAEFIK_HOST=at-mintel.localhost
|
||||||
|
DIRECTUS_HOST=cms.localhost
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
NEXT_PUBLIC_BASE_URL=http://at-mintel.localhost
|
||||||
|
|
||||||
|
# Directus
|
||||||
|
DIRECTUS_URL=http://cms.localhost
|
||||||
|
DIRECTUS_KEY=F9IIfahEjPq6NZhKyRLw516D8GotuFj79EGK7pGfIWg=
|
||||||
|
DIRECTUS_SECRET=OZfxMu8lBxzaEnFGRKreNBoJpRiRu58U+HsVg2yWk4o=
|
||||||
|
CORS_ENABLED=true
|
||||||
|
CORS_ORIGIN=true
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
DIRECTUS_ADMIN_EMAIL=mmintel@mintel.me
|
||||||
|
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||||
|
DIRECTUS_DB_NAME=directus
|
||||||
|
DIRECTUS_DB_USER=directus
|
||||||
|
DIRECTUS_DB_PASSWORD=mintel-db-pass
|
||||||
|
|
||||||
|
# Sentry / Glitchtip
|
||||||
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
# Analytics (Umami)
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Project
|
# Project
|
||||||
IMAGE_TAG=v1.7.3
|
IMAGE_TAG=v1.8.2
|
||||||
PROJECT_NAME=sample-website
|
PROJECT_NAME=sample-website
|
||||||
PROJECT_COLOR=#82ed20
|
PROJECT_COLOR=#82ed20
|
||||||
|
|
||||||
|
|||||||
4
.eslintignore
Normal file
4
.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
**/index.js
|
||||||
|
**/dist/**
|
||||||
|
packages/cms-infra/extensions/**
|
||||||
|
packages/cms-infra/extensions/**
|
||||||
@@ -1,16 +1,37 @@
|
|||||||
|
|
||||||
# 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/v* ]]; 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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sample-website",
|
"name": "sample-website",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
1
directus/uploads/directus-health-file
Normal file
1
directus/uploads/directus-health-file
Normal file
@@ -0,0 +1 @@
|
|||||||
|
S9WsV
|
||||||
@@ -24,6 +24,12 @@ services:
|
|||||||
|
|
||||||
directus:
|
directus:
|
||||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- infra
|
||||||
@@ -35,7 +41,7 @@ services:
|
|||||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
|
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
|
||||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
|
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
|
||||||
DB_CLIENT: 'pg'
|
DB_CLIENT: 'pg'
|
||||||
DB_HOST: 'directus-db'
|
DB_HOST: 'at-mintel-directus-db'
|
||||||
DB_PORT: '5432'
|
DB_PORT: '5432'
|
||||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||||
@@ -53,7 +59,7 @@ services:
|
|||||||
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
||||||
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
||||||
|
|
||||||
directus-db:
|
at-mintel-directus-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -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/**",
|
||||||
|
|||||||
@@ -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": "./scripts/sync-extensions.sh && 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",
|
||||||
@@ -52,7 +55,7 @@
|
|||||||
"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.9",
|
"version": "1.8.2",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,16 +2,13 @@
|
|||||||
"name": "acquisition-manager",
|
"name": "acquisition-manager",
|
||||||
"description": "Custom High-Fidelity Acquisition Management for Directus",
|
"description": "Custom High-Fidelity Acquisition Management for Directus",
|
||||||
"icon": "account_balance_wallet",
|
"icon": "account_balance_wallet",
|
||||||
"version": "1.0.0",
|
"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",
|
||||||
@@ -20,7 +17,7 @@
|
|||||||
"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 && cp -f dist/index.js index.js 2>/dev/null || true",
|
||||||
"dev": "directus-extension build -w"
|
"dev": "directus-extension build -w"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { defineModule } from '@directus/extensions-sdk';
|
import { defineModule } from "@directus/extensions-sdk";
|
||||||
import ModuleComponent from './module.vue';
|
import ModuleComponent from "./module.vue";
|
||||||
|
|
||||||
export default defineModule({
|
export default defineModule({
|
||||||
id: 'acquisition-manager',
|
id: "acquisition-manager",
|
||||||
name: 'Acquisition Manager',
|
name: "Acquisition",
|
||||||
icon: 'account_balance_wallet',
|
icon: "auto_awesome",
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: "",
|
||||||
component: ModuleComponent,
|
component: ModuleComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ":id",
|
||||||
|
component: ModuleComponent,
|
||||||
|
props: true
|
||||||
|
}
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,445 @@
|
|||||||
<template>
|
<template>
|
||||||
<private-view title="Acquisition Manager">
|
<private-view title="Acquisition Manager">
|
||||||
<div class="acquisition-manager">
|
<template #navigation>
|
||||||
<h1>Acquisition Manager</h1>
|
<v-list nav>
|
||||||
<p>Modern Industrial Acquisition Management Interface</p>
|
<v-list-item @click="showAddLead = true" clickable>
|
||||||
|
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
|
||||||
|
<v-list-item-content>
|
||||||
|
<v-text-overflow text="Neuen Lead anlegen" />
|
||||||
|
</v-list-item-content>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-for="lead in leads"
|
||||||
|
:key="lead.id"
|
||||||
|
:active="selectedLeadId === lead.id"
|
||||||
|
class="lead-item"
|
||||||
|
clickable
|
||||||
|
@click="selectLead(lead.id)"
|
||||||
|
>
|
||||||
|
<v-list-item-icon>
|
||||||
|
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
|
||||||
|
</v-list-item-icon>
|
||||||
|
<v-list-item-content>
|
||||||
|
<v-text-overflow :text="getCompanyName(lead)" />
|
||||||
|
</v-list-item-content>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #title-outer:after>
|
||||||
|
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
|
||||||
|
{{ notice.message }}
|
||||||
|
</v-notice>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<v-notice type="success" style="margin-bottom: 16px;">
|
||||||
|
DEBUG: Module Version 1.1.0 - Native Build - {{ new Date().toISOString() }}
|
||||||
|
</v-notice>
|
||||||
|
|
||||||
|
<div v-if="!selectedLead" class="empty-state">
|
||||||
|
<v-info title="Lead auswählen" icon="auto_awesome" center>
|
||||||
|
Wähle einen Lead in der Navigation aus oder
|
||||||
|
<v-button x-small @click="showAddLead = true">registriere einen neuen Lead</v-button>.
|
||||||
|
</v-info>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="title">{{ getCompanyName(selectedLead) }}</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
<v-icon name="language" x-small />
|
||||||
|
<a :href="selectedLead.website_url" target="_blank" class="url-link">
|
||||||
|
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }}
|
||||||
|
</a>
|
||||||
|
· Status: {{ selectedLead.status.toUpperCase() }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<v-button
|
||||||
|
v-if="selectedLead.status === 'new'"
|
||||||
|
secondary
|
||||||
|
:loading="loadingAudit"
|
||||||
|
@click="runAudit"
|
||||||
|
>
|
||||||
|
<v-icon name="settings_suggest" left />
|
||||||
|
Audit starten
|
||||||
|
</v-button>
|
||||||
|
|
||||||
|
<template v-if="selectedLead.status === 'audit_ready'">
|
||||||
|
<v-button secondary :loading="loadingEmail" @click="sendAuditEmail">
|
||||||
|
<v-icon name="mail" left />
|
||||||
|
Audit E-Mail
|
||||||
|
</v-button>
|
||||||
|
<v-button :loading="loadingPdf" @click="generatePdf">
|
||||||
|
<v-icon name="picture_as_pdf" left />
|
||||||
|
PDF Erstellen
|
||||||
|
</v-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-button v-if="selectedLead.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
|
||||||
|
<v-icon name="open_in_new" />
|
||||||
|
</v-button>
|
||||||
|
|
||||||
|
<v-button
|
||||||
|
v-if="selectedLead.audit_pdf_path"
|
||||||
|
primary
|
||||||
|
: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>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawer: New Lead -->
|
||||||
|
<v-drawer
|
||||||
|
v-model="showAddLead"
|
||||||
|
title="Neuen Lead registrieren"
|
||||||
|
icon="person_add"
|
||||||
|
@cancel="showAddLead = false"
|
||||||
|
>
|
||||||
|
<div class="drawer-content">
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Organisation / Firma (Zentral)</span>
|
||||||
|
<v-select
|
||||||
|
v-model="newLead.company"
|
||||||
|
:items="companyOptions"
|
||||||
|
placeholder="Bestehende Firma auswählen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Organisation / Firma (Legacy / Neu)</span>
|
||||||
|
<v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Website URL</span>
|
||||||
|
<v-input v-model="newLead.website_url" placeholder="https://..." />
|
||||||
|
</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">
|
||||||
|
<span class="label">Briefing / Fokus</span>
|
||||||
|
<v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Kontaktperson (Optional)</span>
|
||||||
|
<v-select
|
||||||
|
v-model="newLead.contact_person"
|
||||||
|
:items="peopleOptions"
|
||||||
|
placeholder="Person auswählen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-actions">
|
||||||
|
<v-button primary block :loading="savingLead" @click="saveLead">Lead speichern</v-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-drawer>
|
||||||
</private-view>
|
</private-view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Logic will be added here
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const leads = ref<any[]>([]);
|
||||||
|
const selectedLeadId = ref<string | null>(null);
|
||||||
|
const loadingAudit = ref(false);
|
||||||
|
const loadingPdf = ref(false);
|
||||||
|
const loadingEmail = ref(false);
|
||||||
|
const showAddLead = ref(false);
|
||||||
|
const savingLead = ref(false);
|
||||||
|
const notice = ref<{ type: string; message: string } | null>(null);
|
||||||
|
|
||||||
|
const newLead = ref({
|
||||||
|
company_name: '', // Legacy
|
||||||
|
company: null,
|
||||||
|
website_url: '',
|
||||||
|
contact_name: '', // Legacy
|
||||||
|
contact_email: '', // Legacy
|
||||||
|
contact_person: null,
|
||||||
|
briefing: '',
|
||||||
|
status: 'new'
|
||||||
|
});
|
||||||
|
|
||||||
|
const companies = ref<any[]>([]);
|
||||||
|
const people = ref<any[]>([]);
|
||||||
|
|
||||||
|
const companyOptions = computed(() =>
|
||||||
|
companies.value.map(c => ({
|
||||||
|
text: c.name,
|
||||||
|
value: c.id
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const peopleOptions = computed(() =>
|
||||||
|
people.value.map(p => ({
|
||||||
|
text: `${p.first_name} ${p.last_name}`,
|
||||||
|
value: p.id
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
function getCompanyName(lead: any) {
|
||||||
|
if (lead.company) {
|
||||||
|
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || lead.company_name);
|
||||||
|
}
|
||||||
|
return lead.company_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonName(id: string | any) {
|
||||||
|
if (!id) return '';
|
||||||
|
if (typeof id === 'object') return `${id.first_name} ${id.last_name}`;
|
||||||
|
const person = people.value.find(p => p.id === id);
|
||||||
|
return person ? `${person.first_name} ${person.last_name}` : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPerson(id: string) {
|
||||||
|
// Logic to navigate to people manager or open details
|
||||||
|
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
|
||||||
|
|
||||||
|
onMounted(fetchData);
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLeads() {
|
||||||
|
await fetchData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLead(id: string) {
|
||||||
|
selectedLeadId.value = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAudit() {
|
||||||
|
if (!selectedLeadId.value) return;
|
||||||
|
loadingAudit.value = true;
|
||||||
|
try {
|
||||||
|
await api.post(`/acquisition/audit/${selectedLeadId.value}`);
|
||||||
|
notice.value = { type: 'success', message: 'Audit erfolgreich gestartet!' };
|
||||||
|
await fetchLeads();
|
||||||
|
} catch (e: any) {
|
||||||
|
notice.value = { type: 'danger', message: `Fehler beim Audit: ${e.message}` };
|
||||||
|
} finally {
|
||||||
|
loadingAudit.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAuditEmail() {
|
||||||
|
if (!selectedLeadId.value) return;
|
||||||
|
loadingEmail.value = true;
|
||||||
|
try {
|
||||||
|
await api.post(`/acquisition/audit-email/${selectedLeadId.value}`);
|
||||||
|
notice.value = { type: 'success', message: 'Audit E-Mail versendet!' };
|
||||||
|
await fetchLeads();
|
||||||
|
} catch (e: any) {
|
||||||
|
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
|
||||||
|
} finally {
|
||||||
|
loadingEmail.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePdf() {
|
||||||
|
if (!selectedLeadId.value) return;
|
||||||
|
loadingPdf.value = true;
|
||||||
|
try {
|
||||||
|
await api.post(`/acquisition/estimate/${selectedLeadId.value}`);
|
||||||
|
notice.value = { type: 'success', message: 'Angebot (PDF) wurde generiert!' };
|
||||||
|
await fetchLeads();
|
||||||
|
} catch (e: any) {
|
||||||
|
notice.value = { type: 'danger', message: `Fehler bei PDF Generierung: ${e.message}` };
|
||||||
|
} finally {
|
||||||
|
loadingPdf.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEstimateEmail() {
|
||||||
|
if (!selectedLeadId.value) return;
|
||||||
|
loadingEmail.value = true;
|
||||||
|
try {
|
||||||
|
await api.post(`/acquisition/estimate-email/${selectedLeadId.value}`);
|
||||||
|
notice.value = { type: 'success', message: 'Angebot erfolgreich versendet!' };
|
||||||
|
await fetchLeads();
|
||||||
|
} catch (e: any) {
|
||||||
|
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
|
||||||
|
} finally {
|
||||||
|
loadingEmail.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPdf() {
|
||||||
|
if (!selectedLead.value?.audit_pdf_path) return;
|
||||||
|
window.open(`${window.location.origin}/assets/${selectedLead.value.audit_pdf_path}`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLead() {
|
||||||
|
if (!newLead.value.company_name && !newLead.value.company) {
|
||||||
|
notice.value = { type: 'danger', message: 'Firma oder Firmenname erforderlich.' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savingLead.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
...newLead.value
|
||||||
|
};
|
||||||
|
await api.post('/items/leads', payload);
|
||||||
|
notice.value = { type: 'success', message: 'Lead erfolgreich registriert!' };
|
||||||
|
showAddLead.value = false;
|
||||||
|
await fetchLeads();
|
||||||
|
selectedLeadId.value = payload.id;
|
||||||
|
newLead.value = {
|
||||||
|
company_name: '',
|
||||||
|
company: null,
|
||||||
|
website_url: '',
|
||||||
|
contact_name: '',
|
||||||
|
contact_email: '',
|
||||||
|
contact_person: null,
|
||||||
|
briefing: '',
|
||||||
|
status: 'new'
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
|
||||||
|
} finally {
|
||||||
|
savingLead.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusIcon(status: string) {
|
||||||
|
switch(status) {
|
||||||
|
case 'new': return 'fiber_new';
|
||||||
|
case 'auditing': return 'hourglass_empty';
|
||||||
|
case 'audit_ready': return 'check_circle';
|
||||||
|
case 'contacted': return 'mail_outline';
|
||||||
|
default: return 'help_outline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string) {
|
||||||
|
switch(status) {
|
||||||
|
case 'new': return 'var(--theme--primary)';
|
||||||
|
case 'auditing': return 'var(--theme--warning)';
|
||||||
|
case 'audit_ready': return 'var(--theme--success)';
|
||||||
|
case 'contacted': return 'var(--theme--secondary)';
|
||||||
|
default: return 'var(--theme--foreground-subdued)';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.acquisition-manager {
|
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; overflow-y: auto; }
|
||||||
padding: 20px;
|
.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: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; }
|
||||||
|
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.field.full { grid-column: span 2; }
|
||||||
|
.label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
||||||
|
.value { font-size: 15px; color: var(--theme--foreground); }
|
||||||
|
.text-block { line-height: 1.6; white-space: pre-wrap; background: var(--theme--background-subdued); padding: 16px; border-radius: 8px; }
|
||||||
|
|
||||||
|
.ai-observations { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; }
|
||||||
|
.metrics { display: flex; gap: 32px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; }
|
||||||
|
.page-title { font-weight: 600; }
|
||||||
|
.page-url { font-family: var(--family-monospace); font-size: 12px; color: var(--theme--foreground-subdued); }
|
||||||
|
|
||||||
|
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
||||||
|
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
:deep(.v-list-item) { cursor: pointer !important; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url);
|
|||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
const entryPoint = resolve(__dirname, 'src/index.ts');
|
const entryPoint = resolve(__dirname, 'src/index.ts');
|
||||||
const outfile = resolve(__dirname, 'index.js');
|
const outfile = resolve(__dirname, 'dist/index.js');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mkdirSync(dirname(outfile), { recursive: true });
|
mkdirSync(dirname(outfile), { recursive: true });
|
||||||
@@ -21,20 +21,25 @@ build({
|
|||||||
platform: 'node',
|
platform: 'node',
|
||||||
target: 'node18',
|
target: 'node18',
|
||||||
outfile: outfile,
|
outfile: outfile,
|
||||||
format: 'esm',
|
jsx: 'automatic',
|
||||||
external: [],
|
loader: {
|
||||||
|
'.tsx': 'tsx',
|
||||||
|
'.ts': 'ts',
|
||||||
|
'.js': 'js',
|
||||||
|
},
|
||||||
|
external: ["@react-pdf/renderer", "react", "react-dom", "jsdom", "jsdom/*", "jquery", "jquery/*", "canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
|
||||||
plugins: [{
|
plugins: [{
|
||||||
name: 'mock-jquery',
|
|
||||||
setup(build) {
|
|
||||||
build.onResolve({ filter: /^jquery$/ }, args => ({ path: args.path, namespace: 'mock-jquery' }));
|
|
||||||
build.onLoad({ filter: /.*/, namespace: 'mock-jquery' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
name: 'mock-canvas',
|
name: 'mock-canvas',
|
||||||
setup(build) {
|
setup(build) {
|
||||||
build.onResolve({ filter: /^canvas$/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
|
build.onResolve({ filter: /^canvas/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
|
||||||
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||||
}
|
}
|
||||||
|
}, {
|
||||||
|
name: 'mock-jsdom',
|
||||||
|
setup(build) {
|
||||||
|
build.onResolve({ filter: /^jsdom/ }, args => ({ path: args.path, namespace: 'mock-jsdom' }));
|
||||||
|
build.onLoad({ filter: /.*/, namespace: 'mock-jsdom' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||||
|
}
|
||||||
}]
|
}]
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
console.log("Build succeeded!");
|
console.log("Build succeeded!");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "acquisition",
|
"name": "acquisition",
|
||||||
"version": "1.0.0",
|
"version": "1.8.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"directus:extension": {
|
"directus:extension": {
|
||||||
"type": "endpoint",
|
"type": "endpoint",
|
||||||
"path": "index.js",
|
"path": "dist/index.js",
|
||||||
"source": "src/index.ts",
|
"source": "src/index.ts",
|
||||||
"host": "^11.0.0"
|
"host": "^11.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.js",
|
"build": "node build.mjs",
|
||||||
"dev": "node build.js --watch"
|
"dev": "node build.mjs --watch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
"esbuild": "^0.25.0",
|
"@mintel/pdf": "workspace:*",
|
||||||
"typescript": "^5.6.3"
|
"@mintel/mail": "workspace:*",
|
||||||
},
|
"esbuild": "^0.25.0",
|
||||||
"dependencies": {
|
"typescript": "^5.6.3"
|
||||||
"jquery": "^3.7.1",
|
},
|
||||||
"react": "^19.2.4",
|
"dependencies": {
|
||||||
"react-dom": "^19.2.4"
|
"jquery": "^3.7.1",
|
||||||
}
|
"react": "^19.2.4",
|
||||||
}
|
"react-dom": "^19.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,176 @@
|
|||||||
import { defineEndpoint } from '@directus/extensions-sdk';
|
import { defineEndpoint } from "@directus/extensions-sdk";
|
||||||
|
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
|
||||||
|
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as fs from "fs";
|
||||||
|
|
||||||
export default defineEndpoint((router) => {
|
export default defineEndpoint((router, { services, env }) => {
|
||||||
router.get('/ping', (req, res) => res.send('pong'));
|
const { ItemsService, MailService } = services;
|
||||||
|
|
||||||
|
router.get("/ping", (req, res) => res.send("pong"));
|
||||||
|
|
||||||
|
router.post("/audit/:id", async (req: any, res: any) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lead = await leadsService.readOne(id);
|
||||||
|
if (!lead) return res.status(404).send({ error: "Lead not found" });
|
||||||
|
|
||||||
|
await leadsService.updateOne(id, { status: "auditing" });
|
||||||
|
|
||||||
|
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
|
||||||
|
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments);
|
||||||
|
|
||||||
|
await leadsService.updateOne(id, {
|
||||||
|
status: "audit_ready",
|
||||||
|
ai_state: result.state,
|
||||||
|
audit_context: JSON.stringify(result.usage),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send({ success: true, result });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Audit failed:", error);
|
||||||
|
await leadsService.updateOne(id, { status: "new", comments: `Audit failed: ${error.message}` });
|
||||||
|
res.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/audit-email/:id", async (req: any, res: any) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { ItemsService, MailService } = services;
|
||||||
|
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||||
|
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||||
|
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
|
||||||
|
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
|
||||||
|
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
|
||||||
|
|
||||||
|
let recipientEmail = lead.contact_email;
|
||||||
|
let companyName = lead.company?.name || lead.company_name;
|
||||||
|
|
||||||
|
if (lead.contact_person) {
|
||||||
|
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||||
|
|
||||||
|
if (lead.contact_person.company) {
|
||||||
|
const personCompany = await companiesService.readOne(lead.contact_person.company);
|
||||||
|
companyName = personCompany?.name || companyName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
|
||||||
|
|
||||||
|
const auditHighlights = [
|
||||||
|
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
|
||||||
|
...(lead.ai_state.sitemap || []).slice(0, 3).map((item: any) => `Potenzial in: ${item.category}`),
|
||||||
|
];
|
||||||
|
|
||||||
|
const html = await render(createElement(SiteAuditTemplate, {
|
||||||
|
companyName: companyName,
|
||||||
|
websiteUrl: lead.website_url,
|
||||||
|
auditHighlights
|
||||||
|
}));
|
||||||
|
|
||||||
|
await mailService.send({
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
|
||||||
|
html
|
||||||
|
});
|
||||||
|
|
||||||
|
await leadsService.updateOne(id, {
|
||||||
|
status: "contacted",
|
||||||
|
last_contacted_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Audit Email failed:", error);
|
||||||
|
res.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/estimate/:id", async (req: any, res: any) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lead = await leadsService.readOne(id);
|
||||||
|
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or AI state not found" });
|
||||||
|
|
||||||
|
const pdfEngine = new PdfEngine();
|
||||||
|
const filename = `estimate_${id}_${Date.now()}.pdf`;
|
||||||
|
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||||
|
const outputPath = path.join(storageRoot, filename);
|
||||||
|
|
||||||
|
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
|
||||||
|
|
||||||
|
await leadsService.updateOne(id, {
|
||||||
|
audit_pdf_path: filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send({ success: true, filename });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("PDF Generation failed:", error);
|
||||||
|
res.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/estimate-email/:id", async (req: any, res: any) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||||
|
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||||
|
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
|
||||||
|
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
|
||||||
|
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
|
||||||
|
|
||||||
|
let recipientEmail = lead.contact_email;
|
||||||
|
let companyName = lead.company?.name || lead.company_name;
|
||||||
|
|
||||||
|
if (lead.contact_person) {
|
||||||
|
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||||
|
|
||||||
|
if (lead.contact_person.company) {
|
||||||
|
const personCompany = await companiesService.readOne(lead.contact_person.company);
|
||||||
|
companyName = personCompany?.name || companyName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
|
||||||
|
|
||||||
|
const html = await render(createElement(ProjectEstimateTemplate, {
|
||||||
|
companyName: companyName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||||
|
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
|
||||||
|
|
||||||
|
await mailService.send({
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: `Ihre Projekt-Schätzung: ${companyName}`,
|
||||||
|
html,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: `Angebot_${companyName}.pdf`,
|
||||||
|
content: fs.readFileSync(attachmentPath)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
await leadsService.updateOne(id, {
|
||||||
|
status: "contacted",
|
||||||
|
last_contacted_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Estimate Email failed:", error);
|
||||||
|
res.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cli",
|
"name": "@mintel/cli",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
41
packages/cloner-library/build.mjs
Normal file
41
packages/cloner-library/build.mjs
Normal 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);
|
||||||
|
});
|
||||||
30
packages/cloner-library/package.json
Normal file
30
packages/cloner-library/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/cloner",
|
||||||
|
"version": "1.8.2",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
93
packages/cloner-library/src/AssetManager.ts
Normal file
93
packages/cloner-library/src/AssetManager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
184
packages/cloner-library/src/PageCloner.ts
Normal file
184
packages/cloner-library/src/PageCloner.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
packages/cloner-library/src/WebsiteCloner.ts
Normal file
123
packages/cloner-library/src/WebsiteCloner.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/cloner-library/src/index.ts
Normal file
3
packages/cloner-library/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./AssetManager.js";
|
||||||
|
export * from "./PageCloner.js";
|
||||||
|
export * from "./WebsiteCloner.js";
|
||||||
17
packages/cloner-library/tsconfig.json
Normal file
17
packages/cloner-library/tsconfig.json
Normal 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/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
packages/cms-infra/database/data.db
Normal file → Executable file
BIN
packages/cms-infra/database/data.db
Normal file → Executable file
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
|||||||
"name": "acquisition-manager",
|
"name": "acquisition-manager",
|
||||||
"description": "Custom High-Fidelity Acquisition Management for Directus",
|
"description": "Custom High-Fidelity Acquisition Management for Directus",
|
||||||
"icon": "account_balance_wallet",
|
"icon": "account_balance_wallet",
|
||||||
"version": "1.0.0",
|
"version": "1.7.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "acquisition",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "endpoint",
|
|
||||||
"path": "index.js",
|
|
||||||
"source": "src/index.ts",
|
|
||||||
"host": "^11.0.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "node build.js",
|
|
||||||
"dev": "node build.js --watch"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"typescript": "^5.6.3"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"jquery": "^3.7.1",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-dom": "^19.2.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "customer-manager",
|
"name": "customer-manager",
|
||||||
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||||
"icon": "supervisor_account",
|
"icon": "supervisor_account",
|
||||||
"version": "1.7.3",
|
"version": "1.7.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
@@ -27,4 +27,4 @@
|
|||||||
"@directus/extensions-sdk": "11.0.2",
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "feedback-commander",
|
"name": "feedback-commander",
|
||||||
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||||
"icon": "view_kanban",
|
"icon": "view_kanban",
|
||||||
"version": "1.7.3",
|
"version": "1.7.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,30 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "people-manager",
|
"name": "people-manager",
|
||||||
"description": "Custom High-Fidelity People Management for Directus",
|
"description": "Custom High-Fidelity People Management for Directus",
|
||||||
"icon": "person",
|
"icon": "person",
|
||||||
"version": "1.0.0",
|
"version": "1.7.12",
|
||||||
|
"type": "module",
|
||||||
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-extension-module"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"directus:extension": {
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"path": "index.js",
|
||||||
"directus",
|
"source": "src/index.ts",
|
||||||
"directus-extension",
|
"host": "*",
|
||||||
"directus-extension-module"
|
"name": "People Manager"
|
||||||
],
|
},
|
||||||
"files": [
|
"scripts": {
|
||||||
"dist"
|
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||||
],
|
"dev": "directus-extension build -w"
|
||||||
"directus:extension": {
|
},
|
||||||
"type": "module",
|
"devDependencies": {
|
||||||
"path": "index.js",
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
"source": "src/index.ts",
|
"vue": "^3.4.0"
|
||||||
"host": "*",
|
}
|
||||||
"name": "People Manager"
|
}
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
|
||||||
"dev": "directus-extension build -w"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cms-infra",
|
"name": "@mintel/cms-infra",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1203
packages/cms-infra/schema/current.yaml
Normal file
1203
packages/cms-infra/schema/current.yaml
Normal file
File diff suppressed because it is too large
Load Diff
2046
packages/cms-infra/schema/current_v2.yaml
Normal file
2046
packages/cms-infra/schema/current_v2.yaml
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
27
packages/company-manager/package.json
Normal file
27
packages/company-manager/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "company-manager",
|
||||||
|
"description": "Central Company Management for Directus",
|
||||||
|
"icon": "business",
|
||||||
|
"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": "*",
|
||||||
|
"name": "Company Manager"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
|
||||||
|
"dev": "directus-extension build -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"vue": "^3.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,13 @@
|
|||||||
"name": "customer-manager",
|
"name": "customer-manager",
|
||||||
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||||
"icon": "supervisor_account",
|
"icon": "supervisor_account",
|
||||||
"version": "1.7.3",
|
"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",
|
||||||
@@ -20,11 +17,11 @@
|
|||||||
"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 && cp -f dist/index.js index.js 2>/dev/null || true",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,15 @@
|
|||||||
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
|
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Zentrale Person (Verknüpfung)</span>
|
||||||
|
<v-select
|
||||||
|
v-model="employeeForm.person"
|
||||||
|
:items="peopleOptions"
|
||||||
|
placeholder="Person aus dem People Manager auswählen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<v-divider v-if="isEditingEmployee" />
|
<v-divider v-if="isEditingEmployee" />
|
||||||
|
|
||||||
<div v-if="isEditingEmployee" class="field">
|
<div v-if="isEditingEmployee" class="field">
|
||||||
@@ -158,7 +167,7 @@
|
|||||||
</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';
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
@@ -183,6 +192,7 @@ const employeeForm = ref({
|
|||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
person: null,
|
||||||
temporary_password: ''
|
temporary_password: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,14 +202,22 @@ const tableHeaders = [
|
|||||||
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
|
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
|
||||||
];
|
];
|
||||||
|
|
||||||
async function fetchCompanies() {
|
const people = ref<any[]>([]);
|
||||||
const res = await api.get('/items/companies', {
|
|
||||||
params: {
|
const peopleOptions = computed(() =>
|
||||||
fields: ['id', 'name'],
|
people.value.map(p => ({
|
||||||
sort: 'name',
|
text: `${p.first_name} ${p.last_name} (${p.email})`,
|
||||||
},
|
value: p.id
|
||||||
});
|
}))
|
||||||
companies.value = res.data.data;
|
);
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
const [companiesResp, peopleResp] = await Promise.all([
|
||||||
|
api.get('/items/companies', { params: { sort: 'name', fields: ['id', 'name'] } }),
|
||||||
|
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||||
|
]);
|
||||||
|
companies.value = companiesResp.data.data;
|
||||||
|
people.value = peopleResp.data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectCompany(company: any) {
|
async function selectCompany(company: any) {
|
||||||
@@ -209,7 +227,7 @@ async function selectCompany(company: any) {
|
|||||||
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: company.id } },
|
||||||
fields: ['*'],
|
fields: ['*', 'person.*'],
|
||||||
sort: 'first_name',
|
sort: 'first_name',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -273,6 +291,7 @@ async function openEditEmployee(item: any) {
|
|||||||
first_name: item.first_name || '',
|
first_name: item.first_name || '',
|
||||||
last_name: item.last_name || '',
|
last_name: item.last_name || '',
|
||||||
email: item.email || '',
|
email: item.email || '',
|
||||||
|
person: item.person?.id || item.person || null,
|
||||||
temporary_password: item.temporary_password || ''
|
temporary_password: item.temporary_password || ''
|
||||||
};
|
};
|
||||||
isEditingEmployee.value = true;
|
isEditingEmployee.value = true;
|
||||||
@@ -288,7 +307,8 @@ async function saveEmployee() {
|
|||||||
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
|
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
|
||||||
first_name: employeeForm.value.first_name,
|
first_name: employeeForm.value.first_name,
|
||||||
last_name: employeeForm.value.last_name,
|
last_name: employeeForm.value.last_name,
|
||||||
email: employeeForm.value.email
|
email: employeeForm.value.email,
|
||||||
|
person: employeeForm.value.person
|
||||||
});
|
});
|
||||||
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
|
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
|
||||||
} else {
|
} else {
|
||||||
@@ -296,7 +316,8 @@ async function saveEmployee() {
|
|||||||
first_name: employeeForm.value.first_name,
|
first_name: employeeForm.value.first_name,
|
||||||
last_name: employeeForm.value.last_name,
|
last_name: employeeForm.value.last_name,
|
||||||
email: employeeForm.value.email,
|
email: employeeForm.value.email,
|
||||||
company: selectedCompany.value.id
|
company: selectedCompany.value.id,
|
||||||
|
person: employeeForm.value.person
|
||||||
});
|
});
|
||||||
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
|
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
|
||||||
}
|
}
|
||||||
@@ -343,7 +364,7 @@ function formatDate(dateStr: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchCompanies();
|
fetchData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
31
packages/directus-extension-toolkit/package.json
Normal file
31
packages/directus-extension-toolkit/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/directus-extension-toolkit",
|
||||||
|
"version": "1.8.2",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
packages/directus-extension-toolkit/src/MintelManagerLayout.vue
Normal file
102
packages/directus-extension-toolkit/src/MintelManagerLayout.vue
Normal 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>
|
||||||
62
packages/directus-extension-toolkit/src/MintelSelect.vue
Normal file
62
packages/directus-extension-toolkit/src/MintelSelect.vue
Normal 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>
|
||||||
84
packages/directus-extension-toolkit/src/MintelStatCard.vue
Normal file
84
packages/directus-extension-toolkit/src/MintelStatCard.vue
Normal 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>
|
||||||
3
packages/directus-extension-toolkit/src/index.ts
Normal file
3
packages/directus-extension-toolkit/src/index.ts
Normal 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';
|
||||||
24
packages/directus-extension-toolkit/vite.config.ts
Normal file
24
packages/directus-extension-toolkit/vite.config.ts
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/eslint-config",
|
"name": "@mintel/eslint-config",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -2,16 +2,13 @@
|
|||||||
"name": "feedback-commander",
|
"name": "feedback-commander",
|
||||||
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||||
"icon": "view_kanban",
|
"icon": "view_kanban",
|
||||||
"version": "1.7.3",
|
"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",
|
||||||
@@ -20,7 +17,7 @@
|
|||||||
"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 && cp -f dist/index.js index.js 2>/dev/null || true",
|
||||||
"dev": "directus-extension build -w"
|
"dev": "directus-extension build -w"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/gatekeeper",
|
"name": "@mintel/gatekeeper",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
// 1. URL Parameter Bypass (for automated tests/staging)
|
// 1. URL Parameter Bypass (for automated tests/staging)
|
||||||
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
||||||
|
|
||||||
|
console.log(`[Verify] Check: ${originalUrl} | Cookie: ${session ? "Found" : "Missing"}`);
|
||||||
const host =
|
const host =
|
||||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
||||||
const proto = req.headers.get("x-forwarded-proto") || "https";
|
const proto = req.headers.get("x-forwarded-proto") || "https";
|
||||||
@@ -54,15 +56,17 @@ export async function GET(req: NextRequest) {
|
|||||||
if (session?.value) {
|
if (session?.value) {
|
||||||
if (session.value === password) {
|
if (session.value === password) {
|
||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
|
console.log(`[Verify] Legacy password match`);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(session.value);
|
const payload = JSON.parse(session.value);
|
||||||
if (payload.identity) {
|
if (payload.identity) {
|
||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
identity = payload.identity;
|
identity = payload.identity;
|
||||||
|
console.log(`[Verify] Identity verified: ${identity}`);
|
||||||
}
|
}
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// Fallback or old format
|
console.log(`[Verify] JSON Parse failed for cookie: ${session.value.substring(0, 10)}...`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
async function login(formData: FormData) {
|
async function login(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
const email = formData.get("email") as string;
|
const email = (formData.get("email") as string || "").trim();
|
||||||
const password = formData.get("password") as string;
|
const password = (formData.get("password") as string || "").trim();
|
||||||
|
|
||||||
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
|
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||||
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
|
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||||
@@ -31,19 +31,19 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
let userIdentity = "";
|
let userIdentity = "";
|
||||||
let userCompany: any = null;
|
let userCompany: any = null;
|
||||||
|
|
||||||
// 1. Check Global Admin (from ENV)
|
// 1. Check Generic Code (Guest) - High Priority to prevent autofill traps
|
||||||
if (
|
if (password === expectedCode) {
|
||||||
|
userIdentity = "Guest";
|
||||||
|
}
|
||||||
|
// 2. Check Global Admin (from ENV)
|
||||||
|
else if (
|
||||||
adminEmail &&
|
adminEmail &&
|
||||||
adminPassword &&
|
adminPassword &&
|
||||||
email === adminEmail &&
|
email === adminEmail.trim() &&
|
||||||
password === adminPassword
|
password === adminPassword.trim()
|
||||||
) {
|
) {
|
||||||
userIdentity = "Admin";
|
userIdentity = "Admin";
|
||||||
}
|
}
|
||||||
// 2. Check Generic Code (Guest)
|
|
||||||
else if (!email && password === expectedCode) {
|
|
||||||
userIdentity = "Guest";
|
|
||||||
}
|
|
||||||
// 3. Check Lightweight Client Users (dedicated collection)
|
// 3. Check Lightweight Client Users (dedicated collection)
|
||||||
if (email && password && process.env.INFRA_DIRECTUS_URL) {
|
if (email && password && process.env.INFRA_DIRECTUS_URL) {
|
||||||
try {
|
try {
|
||||||
@@ -116,6 +116,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (userIdentity) {
|
if (userIdentity) {
|
||||||
|
console.log(`[Login] Success: ${userIdentity} | Redirect: ${targetRedirect}`);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
// Store identity in the cookie (simplified for now, ideally signed)
|
// Store identity in the cookie (simplified for now, ideally signed)
|
||||||
const sessionValue = JSON.stringify({
|
const sessionValue = JSON.stringify({
|
||||||
@@ -126,6 +127,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
|
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
console.log(`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`);
|
||||||
|
|
||||||
cookieStore.set(authCookieName, sessionValue, {
|
cookieStore.set(authCookieName, sessionValue, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: !isDev,
|
secure: !isDev,
|
||||||
@@ -136,6 +139,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
});
|
});
|
||||||
redirect(targetRedirect);
|
redirect(targetRedirect);
|
||||||
} else {
|
} else {
|
||||||
|
console.log(`[Login] Failed for inputs. Redirecting back with error.`);
|
||||||
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
|
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/husky-config",
|
"name": "@mintel/husky-config",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,38 +1,13 @@
|
|||||||
# Step 1: Builder image
|
# Step 1: Base image for Next.js builds
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine
|
||||||
RUN apk add --no-cache libc6-compat curl
|
RUN apk add --no-cache libc6-compat curl
|
||||||
|
|
||||||
|
# Enable pnpm
|
||||||
|
RUN corepack enable pnpm && \
|
||||||
|
corepack prepare pnpm@10.2.0 --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable pnpm
|
|
||||||
|
|
||||||
# Step 2: Install dependencies
|
# Final environment
|
||||||
ENV NPM_TOKEN=placeholder
|
ENV NODE_ENV=production
|
||||||
# Copy manifest files specifically for better layer caching
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
|
|
||||||
# Copy package manifest files individually to preserve directory structure
|
|
||||||
COPY packages/cli/package.json ./packages/cli/
|
|
||||||
COPY packages/cms-infra/package.json ./packages/cms-infra/
|
|
||||||
COPY packages/customer-manager/package.json ./packages/customer-manager/
|
|
||||||
COPY packages/eslint-config/package.json ./packages/eslint-config/
|
|
||||||
COPY packages/feedback-commander/package.json ./packages/feedback-commander/
|
|
||||||
COPY packages/gatekeeper/package.json ./packages/gatekeeper/
|
|
||||||
COPY packages/husky-config/package.json ./packages/husky-config/
|
|
||||||
COPY packages/infra/package.json ./packages/infra/
|
|
||||||
COPY packages/mail/package.json ./packages/mail/
|
|
||||||
COPY packages/next-config/package.json ./packages/next-config/
|
|
||||||
COPY packages/next-feedback/package.json ./packages/next-feedback/
|
|
||||||
COPY packages/next-observability/package.json ./packages/next-observability/
|
|
||||||
COPY packages/next-utils/package.json ./packages/next-utils/
|
|
||||||
COPY packages/observability/package.json ./packages/observability/
|
|
||||||
COPY packages/tsconfig/package.json ./packages/tsconfig/
|
|
||||||
# packages/ui does not have a package.json
|
|
||||||
|
|
||||||
# Use a secret for NPM_TOKEN and a standardized cache mount
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
|
||||||
--mount=type=secret,id=NPM_TOKEN \
|
|
||||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
|
||||||
pnpm config set store-dir /pnpm/store && \
|
|
||||||
pnpm i --frozen-lockfile
|
|
||||||
|
|
||||||
# Step 3: Build shared packages
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm --filter "./packages/*" -r build
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/infra",
|
"name": "@mintel/infra",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -43,8 +43,15 @@ for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# 2. Run Garbage Collection
|
# 2. Run Garbage Collection
|
||||||
echo "♻️ Running Registry Garbage Collection..."
|
echo "♻️ Detecting Registry Container..."
|
||||||
docker exec registry-registry-1 bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
|
REGISTRY_CONTAINER=$(docker ps --format "{{.Names}}" | grep registry | head -1 || true)
|
||||||
|
|
||||||
|
if [ -n "$REGISTRY_CONTAINER" ]; then
|
||||||
|
echo "♻️ Running Registry Garbage Collection on $REGISTRY_CONTAINER..."
|
||||||
|
docker exec "$REGISTRY_CONTAINER" bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
|
||||||
|
else
|
||||||
|
echo "⚠️ Registry container not found. Skipping GC."
|
||||||
|
fi
|
||||||
|
|
||||||
# 3. Prune Host Docker resources (Shorter window: 24h)
|
# 3. Prune Host Docker resources (Shorter window: 24h)
|
||||||
echo "🧹 Pruning Host Docker resources..."
|
echo "🧹 Pruning Host Docker resources..."
|
||||||
|
|||||||
93
packages/infra/scripts/wait-for-upstream.sh
Executable file
93
packages/infra/scripts/wait-for-upstream.sh
Executable file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# wait-for-upstream.sh
|
||||||
|
# Usage: ./wait-for-upstream.sh <org/repo> <version_tag> [poll_interval_sec]
|
||||||
|
|
||||||
|
REPO=$1
|
||||||
|
TAG=$2
|
||||||
|
INTERVAL=${3:-30}
|
||||||
|
MAX_RETRIES=40 # ~20 minutes default
|
||||||
|
|
||||||
|
if [[ -z "$REPO" || -z "$TAG" ]]; then
|
||||||
|
echo "❌ Error: REPO and TAG are required."
|
||||||
|
echo "Usage: $0 <org/repo> <version_tag>"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$GITEA_TOKEN" ]]; then
|
||||||
|
echo "❌ Error: GITEA_TOKEN is not set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
GITEA_API="https://git.infra.mintel.me/api/v1"
|
||||||
|
|
||||||
|
echo "🔎 Searching for upstream release $TAG in $REPO..."
|
||||||
|
|
||||||
|
# 1. Get the SHA of the tag to be more precise
|
||||||
|
TAG_INFO=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/tags/$TAG")
|
||||||
|
TARGET_SHA=$(echo "$TAG_INFO" | jq -r '.commit.sha // empty')
|
||||||
|
|
||||||
|
if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == "null" ]]; then
|
||||||
|
echo "⚠️ Warning: Tag $TAG not found yet. Upstream might be lagging."
|
||||||
|
echo " Waiting 15s for tag to appear..."
|
||||||
|
sleep 15
|
||||||
|
TAG_INFO=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/tags/$TAG")
|
||||||
|
TARGET_SHA=$(echo "$TAG_INFO" | jq -r '.commit.sha // empty')
|
||||||
|
|
||||||
|
if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == "null" ]]; then
|
||||||
|
echo "❌ Error: Tag $TAG does not exist in $REPO."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Target SHA for $TAG is $TARGET_SHA"
|
||||||
|
|
||||||
|
# 2. Find the run for the specific SHA
|
||||||
|
# We list recent runs and filter by head_sha
|
||||||
|
RUN_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs?limit=30")
|
||||||
|
RUN_ID=$(echo "$RUN_QUERY" | jq -r ".workflow_runs[] | select(.head_sha == \"$TARGET_SHA\") | .id" | head -n 1)
|
||||||
|
|
||||||
|
if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then
|
||||||
|
echo "ℹ️ No recent action run found for SHA $TARGET_SHA yet."
|
||||||
|
echo " Checking if we should wait or if it was already successful..."
|
||||||
|
|
||||||
|
# Fallback: wait a bit more for new tags
|
||||||
|
echo "⏳ waiting for run to appear..."
|
||||||
|
sleep 20
|
||||||
|
RUN_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs?limit=30")
|
||||||
|
RUN_ID=$(echo "$RUN_QUERY" | jq -r ".workflow_runs[] | select(.head_sha == \"$TARGET_SHA\") | .id" | head -n 1)
|
||||||
|
|
||||||
|
if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then
|
||||||
|
echo "✅ No run found but Tag exists. Assuming manual release or already completed. Proceeding."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "⏳ Waiting for upstream run $RUN_ID status..."
|
||||||
|
|
||||||
|
RETRY_COUNT=0
|
||||||
|
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||||
|
STATUS_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs/$RUN_ID")
|
||||||
|
STATUS=$(echo "$STATUS_QUERY" | jq -r '.status')
|
||||||
|
CONCLUSION=$(echo "$STATUS_QUERY" | jq -r '.conclusion')
|
||||||
|
|
||||||
|
echo " - Current Status: $STATUS (Conclusion: $CONCLUSION)"
|
||||||
|
|
||||||
|
if [[ "$STATUS" == "success" || "$CONCLUSION" == "success" ]]; then
|
||||||
|
echo "✅ Upstream release $TAG is READY."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$STATUS" == "failure" || "$CONCLUSION" == "failure" || "$CONCLUSION" == "cancelled" ]]; then
|
||||||
|
echo "❌ Error: Upstream release $TAG FAILED or was CANCELLED."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " - Still working... waiting $INTERVAL seconds (Attempt $((RETRY_COUNT+1))/$MAX_RETRIES)"
|
||||||
|
sleep $INTERVAL
|
||||||
|
RETRY_COUNT=$((RETRY_COUNT+1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "❌ Error: Timeout waiting for upstream release $TAG."
|
||||||
|
exit 1
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/mail",
|
"name": "@mintel/mail",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"private": false,
|
"private": false,
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ export * from "./layouts/ClientLayout";
|
|||||||
// Export Templates
|
// Export Templates
|
||||||
export * from "./templates/ContactFormNotification";
|
export * from "./templates/ContactFormNotification";
|
||||||
export * from "./templates/ConfirmationMessage";
|
export * from "./templates/ConfirmationMessage";
|
||||||
|
export * from "./templates/FollowUpTemplate";
|
||||||
|
export * from "./templates/ProjectEstimateTemplate";
|
||||||
|
export * from "./templates/SiteAuditTemplate";
|
||||||
|
|||||||
88
packages/mail/src/templates/FollowUpTemplate.tsx
Normal file
88
packages/mail/src/templates/FollowUpTemplate.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Heading, Text, Button } from "@react-email/components";
|
||||||
|
import { MintelLayout } from "../layouts/MintelLayout";
|
||||||
|
|
||||||
|
export interface FollowUpTemplateProps {
|
||||||
|
companyName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FollowUpTemplate = ({
|
||||||
|
companyName,
|
||||||
|
}: FollowUpTemplateProps) => {
|
||||||
|
const preview = `Kurzes Follow-up: ${companyName}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MintelLayout preview={preview}>
|
||||||
|
<Heading style={h1}>Kurzes Follow-up</Heading>
|
||||||
|
<Text style={intro}>
|
||||||
|
Hallo noch einmal,<br /><br />
|
||||||
|
ich wollte mich nur kurz erkundigen, ob Sie bereits Zeit hatten,
|
||||||
|
einen Blick auf das Audit Ihrer Website zu werfen, das ich Ihnen
|
||||||
|
vor ein paar Tagen gesendet habe.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={bodyText}>
|
||||||
|
Vielleicht passt es ja diese Woche für ein kurzes, unverbindliches
|
||||||
|
Telefonat, um die Punkte gemeinsam durchzugehen?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
style={button}
|
||||||
|
href="https://calendly.com/mintel-me/intro"
|
||||||
|
>
|
||||||
|
Treffen vereinbaren
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Text style={footerText}>
|
||||||
|
Beste Grüße,<br />
|
||||||
|
<strong>Marc Mintel</strong>
|
||||||
|
</Text>
|
||||||
|
</MintelLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FollowUpTemplate;
|
||||||
|
|
||||||
|
const h1 = {
|
||||||
|
fontSize: "28px",
|
||||||
|
fontWeight: "900",
|
||||||
|
margin: "0 0 24px",
|
||||||
|
color: "#ffffff",
|
||||||
|
letterSpacing: "-0.04em",
|
||||||
|
};
|
||||||
|
|
||||||
|
const intro = {
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
color: "#cccccc",
|
||||||
|
margin: "0 0 24px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyText = {
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
color: "#888888",
|
||||||
|
margin: "0 0 32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const button = {
|
||||||
|
backgroundColor: "#333333",
|
||||||
|
borderRadius: "0",
|
||||||
|
color: "#ffffff",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
textDecoration: "none",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "12px 24px",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
border: "1px solid #444444",
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerText = {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
lineHeight: "20px",
|
||||||
|
marginTop: "48px",
|
||||||
|
};
|
||||||
86
packages/mail/src/templates/ProjectEstimateTemplate.tsx
Normal file
86
packages/mail/src/templates/ProjectEstimateTemplate.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Heading, Text, Section } from "@react-email/components";
|
||||||
|
import { MintelLayout } from "../layouts/MintelLayout";
|
||||||
|
|
||||||
|
export interface ProjectEstimateTemplateProps {
|
||||||
|
companyName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectEstimateTemplate = ({
|
||||||
|
companyName,
|
||||||
|
}: ProjectEstimateTemplateProps) => {
|
||||||
|
const preview = `Ihre personalisierte Projekt-Schätzung: ${companyName}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MintelLayout preview={preview}>
|
||||||
|
<Heading style={h1}>Ihre Projekt-Schätzung</Heading>
|
||||||
|
<Text style={intro}>
|
||||||
|
Hallo {companyName},<br /><br />
|
||||||
|
vielen Dank für unser Gespräch. Wie versprochen sende ich Ihnen hiermit
|
||||||
|
die detaillierte Schätzung für Ihre neue digitale Webpräsenz.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={infoBox}>
|
||||||
|
<Text style={infoText}>
|
||||||
|
Im Anhang finden Sie das PDF-Dokument mit allen Positionen,
|
||||||
|
Umfängen und dem strategischen Ausblick.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text style={bodyText}>
|
||||||
|
Ich freue mich auf Ihr Feedback und stehe für Rückfragen jederzeit
|
||||||
|
zur Verfügung.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={footerText}>
|
||||||
|
Beste Grüße,<br />
|
||||||
|
<strong>Marc Mintel</strong>
|
||||||
|
</Text>
|
||||||
|
</MintelLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectEstimateTemplate;
|
||||||
|
|
||||||
|
const h1 = {
|
||||||
|
fontSize: "28px",
|
||||||
|
fontWeight: "900",
|
||||||
|
margin: "0 0 24px",
|
||||||
|
color: "#ffffff",
|
||||||
|
letterSpacing: "-0.04em",
|
||||||
|
};
|
||||||
|
|
||||||
|
const intro = {
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
color: "#cccccc",
|
||||||
|
margin: "0 0 24px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const infoBox = {
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
padding: "24px",
|
||||||
|
borderLeft: "4px solid #ffffff",
|
||||||
|
marginBottom: "32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const infoText = {
|
||||||
|
fontSize: "15px",
|
||||||
|
color: "#ffffff",
|
||||||
|
margin: "0",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyText = {
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
color: "#888888",
|
||||||
|
margin: "0 0 32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerText = {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
lineHeight: "20px",
|
||||||
|
marginTop: "48px",
|
||||||
|
};
|
||||||
146
packages/mail/src/templates/SiteAuditTemplate.tsx
Normal file
146
packages/mail/src/templates/SiteAuditTemplate.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Heading, Section, Text, Button, Link } from "@react-email/components";
|
||||||
|
import { MintelLayout } from "../layouts/MintelLayout";
|
||||||
|
|
||||||
|
export interface SiteAuditTemplateProps {
|
||||||
|
companyName: string;
|
||||||
|
auditHighlights: string[];
|
||||||
|
websiteUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SiteAuditTemplate = ({
|
||||||
|
companyName,
|
||||||
|
auditHighlights,
|
||||||
|
websiteUrl,
|
||||||
|
}: SiteAuditTemplateProps) => {
|
||||||
|
const preview = `Analyse Ihrer Webpräsenz: ${companyName}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MintelLayout preview={preview}>
|
||||||
|
<Heading style={h1}>Analyse Ihrer Webpräsenz</Heading>
|
||||||
|
<Text style={intro}>
|
||||||
|
Hallo {companyName},<br /><br />
|
||||||
|
ich habe mir Ihre aktuelle Website ({websiteUrl}) im Detail angeschaut und
|
||||||
|
einige technische sowie strategische Potenziale identifiziert.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={auditContainer}>
|
||||||
|
<Heading as="h2" style={h2}>Audit Highlights</Heading>
|
||||||
|
{auditHighlights.map((highlight, i) => (
|
||||||
|
<div key={i} style={highlightRow}>
|
||||||
|
<div style={bullet} />
|
||||||
|
<Text style={highlightText}>{highlight}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text style={bodyText}>
|
||||||
|
In der heutigen digitalen Landschaft ist eine performante und strategisch
|
||||||
|
ausgerichtete Website kein Luxus mehr, sondern das Fundament für
|
||||||
|
nachhaltiges Wachstum.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={ctaSection}>
|
||||||
|
<Button
|
||||||
|
style={button}
|
||||||
|
href={`mailto:marc@mintel.me?subject=Feedback zum Audit: ${companyName}`}
|
||||||
|
>
|
||||||
|
Audit gemeinsam besprechen
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text style={footerText}>
|
||||||
|
Beste Grüße,<br />
|
||||||
|
<strong>Marc Mintel</strong><br />
|
||||||
|
Digitaler Architekt
|
||||||
|
</Text>
|
||||||
|
</MintelLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SiteAuditTemplate;
|
||||||
|
|
||||||
|
const h1 = {
|
||||||
|
fontSize: "28px",
|
||||||
|
fontWeight: "900",
|
||||||
|
margin: "0 0 24px",
|
||||||
|
color: "#ffffff",
|
||||||
|
letterSpacing: "-0.04em",
|
||||||
|
};
|
||||||
|
|
||||||
|
const h2 = {
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "900",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
color: "#444444",
|
||||||
|
margin: "0 0 16px",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
};
|
||||||
|
|
||||||
|
const intro = {
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
color: "#cccccc",
|
||||||
|
margin: "0 0 32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const auditContainer = {
|
||||||
|
backgroundColor: "#151515",
|
||||||
|
padding: "32px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "32px",
|
||||||
|
border: "1px solid #222222",
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightRow = {
|
||||||
|
display: "flex" as const,
|
||||||
|
alignItems: "flex-start" as const,
|
||||||
|
marginBottom: "12px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const bullet = {
|
||||||
|
width: "6px",
|
||||||
|
height: "6px",
|
||||||
|
backgroundColor: "#4CAF50",
|
||||||
|
marginTop: "8px",
|
||||||
|
marginRight: "12px",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const highlightText = {
|
||||||
|
fontSize: "15px",
|
||||||
|
color: "#ffffff",
|
||||||
|
margin: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyText = {
|
||||||
|
fontSize: "16px",
|
||||||
|
lineHeight: "24px",
|
||||||
|
color: "#888888",
|
||||||
|
margin: "0 0 32px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctaSection = {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
marginBottom: "48px",
|
||||||
|
};
|
||||||
|
|
||||||
|
const button = {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderRadius: "0",
|
||||||
|
color: "#000000",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
textDecoration: "none",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "16px 32px",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
};
|
||||||
|
|
||||||
|
const footerText = {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
lineHeight: "20px",
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-config",
|
"name": "@mintel/next-config",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-feedback",
|
"name": "@mintel/next-feedback",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -107,15 +108,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(() => {
|
||||||
|
|||||||
@@ -6,7 +6,4 @@ export default defineConfig({
|
|||||||
dts: true,
|
dts: true,
|
||||||
clean: true,
|
clean: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
banner: {
|
|
||||||
js: "'use client';",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-observability",
|
"name": "@mintel/next-observability",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-utils",
|
"name": "@mintel/next-utils",
|
||||||
"version": "1.7.15",
|
"version": "1.8.2",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/observability",
|
"name": "@mintel/observability",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
57
packages/pdf-library/build.mjs
Normal file
57
packages/pdf-library/build.mjs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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'),
|
||||||
|
resolve(__dirname, 'src/server.ts')
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
console.log(`Building entry points...`);
|
||||||
|
|
||||||
|
build({
|
||||||
|
entryPoints: entryPoints,
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node18',
|
||||||
|
outdir: resolve(__dirname, 'dist'),
|
||||||
|
format: 'esm',
|
||||||
|
jsx: 'automatic',
|
||||||
|
loader: {
|
||||||
|
'.tsx': 'tsx',
|
||||||
|
'.ts': 'ts',
|
||||||
|
'.js': 'js',
|
||||||
|
},
|
||||||
|
external: ["@react-pdf/renderer", "react", "react-dom", "jsdom", "jsdom/*", "jquery", "jquery/*", "canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
|
||||||
|
plugins: [{
|
||||||
|
name: 'mock-canvas',
|
||||||
|
setup(build) {
|
||||||
|
build.onResolve({ filter: /^canvas/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
|
||||||
|
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
name: 'mock-jsdom',
|
||||||
|
setup(build) {
|
||||||
|
build.onResolve({ filter: /^jsdom/ }, args => ({ path: args.path, namespace: 'mock-jsdom' }));
|
||||||
|
build.onLoad({ filter: /.*/, namespace: 'mock-jsdom' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}).then(() => {
|
||||||
|
console.log("Build succeeded!");
|
||||||
|
}).catch((e) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
38
packages/pdf-library/package.json
Normal file
38
packages/pdf-library/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/pdf",
|
||||||
|
"version": "1.8.2",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"./server": {
|
||||||
|
"types": "./dist/server.d.ts",
|
||||||
|
"import": "./dist/server.js",
|
||||||
|
"default": "./dist/server.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "node build.mjs --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@crawlee/cheerio": "^3.16.0",
|
||||||
|
"@mintel/mail": "workspace:*",
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
241
packages/pdf-library/src/components/AgbsPDF.tsx
Normal file
241
packages/pdf-library/src/components/AgbsPDF.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Page as PDFPage,
|
||||||
|
Text as PDFText,
|
||||||
|
View as PDFView,
|
||||||
|
StyleSheet as PDFStyleSheet,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import {
|
||||||
|
pdfStyles,
|
||||||
|
Header,
|
||||||
|
Footer,
|
||||||
|
FoldingMarks,
|
||||||
|
DocumentTitle,
|
||||||
|
} from "./pdf/SharedUI.js";
|
||||||
|
import { SimpleLayout } from "./pdf/SimpleLayout.js";
|
||||||
|
|
||||||
|
const localStyles = PDFStyleSheet.create({
|
||||||
|
sectionContainer: {
|
||||||
|
marginTop: 0,
|
||||||
|
},
|
||||||
|
agbSection: {
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
labelRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "baseline",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
monoNumber: {
|
||||||
|
fontSize: 7,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#94a3b8",
|
||||||
|
letterSpacing: 2,
|
||||||
|
width: 25,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#000000",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
officialText: {
|
||||||
|
fontSize: 8,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "#334155",
|
||||||
|
textAlign: "justify",
|
||||||
|
paddingLeft: 25,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const AGBSection = ({
|
||||||
|
index,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
index: string;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={localStyles.agbSection} wrap={false}>
|
||||||
|
<PDFView style={localStyles.labelRow}>
|
||||||
|
<PDFText style={localStyles.monoNumber}>{index}</PDFText>
|
||||||
|
<PDFText style={localStyles.sectionTitle}>{title}</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
<PDFText style={localStyles.officialText}>{children}</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface AgbsPDFProps {
|
||||||
|
headerIcon?: string;
|
||||||
|
footerLogo?: string;
|
||||||
|
mode?: "estimation" | "full";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgbsPDF = ({
|
||||||
|
headerIcon,
|
||||||
|
footerLogo,
|
||||||
|
mode = "full",
|
||||||
|
}: AgbsPDFProps) => {
|
||||||
|
const date = new Date().toLocaleDateString("de-DE", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const companyData = {
|
||||||
|
name: "Marc Mintel",
|
||||||
|
address1: "Georg-Meistermann-Straße 7",
|
||||||
|
address2: "54586 Schüller",
|
||||||
|
ustId: "DE367588065",
|
||||||
|
};
|
||||||
|
|
||||||
|
const bankData = {
|
||||||
|
name: "N26",
|
||||||
|
bic: "NTSBDEB1XXX",
|
||||||
|
iban: "DE50 1001 1001 2620 4328 65",
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<DocumentTitle
|
||||||
|
title="Allgemeine Geschäftsbedingungen"
|
||||||
|
subLines={[`Stand: ${date}`]}
|
||||||
|
/>
|
||||||
|
<PDFView style={localStyles.sectionContainer}>
|
||||||
|
<AGBSection index="01" title="Geltungsbereich">
|
||||||
|
Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge
|
||||||
|
zwischen Marc Mintel (nachfolgend „Auftragnehmer“) und dem jeweiligen
|
||||||
|
Kunden (nachfolgend „Auftraggeber“). Abweichende oder ergänzende
|
||||||
|
Bedingungen des Auftraggebers werden nicht Vertragsbestandteil, auch
|
||||||
|
wenn ihrer Geltung nicht ausdrücklich widersprochen wird.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="02" title="Vertragsgegenstand">
|
||||||
|
Der Auftragnehmer erbringt Dienstleistungen im Bereich:
|
||||||
|
Webentwicklung, technische Umsetzung digitaler Systeme, Funktionen,
|
||||||
|
Schnittstellen und Automatisierungen sowie Hosting, Betrieb und
|
||||||
|
Wartung, sofern ausdrücklich vereinbard. Der Auftragnehmer schuldet
|
||||||
|
ausschließlich die vereinbarte technische Leistung, nicht jedoch einen
|
||||||
|
wirtschaftlichen Erfolg, bestimmte Umsätze, Conversions, Reichweiten,
|
||||||
|
Suchmaschinen-Rankings oder rechtliche Ergebnisse.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="03" title="Mitwirkungspflichten des Auftraggebers">
|
||||||
|
Der Auftraggeber verpflichtet sich, alle zur Leistungserbringung
|
||||||
|
erforderlichen Inhalte, Informationen, Zugänge und Entscheidungen
|
||||||
|
rechtzeitig, vollständig und korrekt bereitzustellen. Hierzu zählen
|
||||||
|
insbesondere Texte, Bilder, Videos, Produktdaten, Freigaben, Feedback,
|
||||||
|
Zugangsdaten sowie rechtlich erforderliche Inhalte (z. B. Impressum,
|
||||||
|
DSGVO). Verzögerungen oder Unterlassungen führen zu Verschiebungen
|
||||||
|
aller Termine ohne Schadensersatzanspruch.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="04" title="Ausführungs- und Bearbeitungszeiten">
|
||||||
|
Angegebene Bearbeitungszeiten sind unverbindliche Schätzungen, keine
|
||||||
|
garantierten Fristen. Fixe Termine oder Deadlines gelten nur, wenn sie
|
||||||
|
ausdrücklich schriftlich als verbindlich vereinbart wurden.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="05" title="Abnahme">
|
||||||
|
Die Leistung gilt als abgenommen, wenn der Auftraggeber sie produktiv
|
||||||
|
nutzt oder innerhalb von 7 Tagen nach Bereitstellung keine
|
||||||
|
wesentlichen Mängel angezeigt werden. Optische Abweichungen,
|
||||||
|
Geschmacksfragen oder subjektive Einschätzungen stellen keine Mängel
|
||||||
|
dar.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="06" title="Haftung">
|
||||||
|
Der Auftragnehmer haftet nur für Schäden, die auf vorsätzlicher oder
|
||||||
|
grob fahrlässiger Pflichtverletzung beruhen. Eine Haftung für
|
||||||
|
entgangenen Gewinn, Umsatzausfälle, Datenverlust,
|
||||||
|
Betriebsunterbrechungen, mittelbare oder Folgeschäden ist
|
||||||
|
ausgeschlossen, soweit gesetzlich zulässig.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="07" title="Verfügbarkeit & Betrieb">
|
||||||
|
Bei vereinbartem Hosting oder Betrieb schuldet der Auftragnehmer keine
|
||||||
|
permanente Verfügbarkeit. Wartungsarbeiten, Updates,
|
||||||
|
Sicherheitsmaßnahmen oder externe Störungen können zu zeitweisen
|
||||||
|
Einschränkungen führen und begründen keine Haftungsansprüche.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="07a" title="Betriebs- und Pflegeleistung">
|
||||||
|
Die Betriebs- und Pflegeleistung umfasst ausschließlich die
|
||||||
|
Sicherstellung des technischen Betriebs, Wartung, Updates,
|
||||||
|
Fehlerbehebung der bestehenden Systeme sowie Pflege bestehender
|
||||||
|
Datensätze ohne Strukturänderung. Nicht Bestandteil sind die
|
||||||
|
Erstellung neuer Inhalte (Blogartikel, News, Produkte), redaktionelle
|
||||||
|
Tätigkeiten, strategische Planung oder der Aufbau neuer
|
||||||
|
Features/Datenmodelle. Leistungen darüber hinaus gelten als
|
||||||
|
Neuentwicklung.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="08" title="Drittanbieter & externe Systeme">
|
||||||
|
Der Auftragnehmer übernimmt keine Verantwortung für Leistungen,
|
||||||
|
Ausfälle oder Änderungen externer Dienste, APIs, Schnittstellen oder
|
||||||
|
Plattformen Dritter. Eine Funktionsfähigkeit kann nur im Rahmen der
|
||||||
|
jeweils aktuellen externen Schnittstellen gewährleistet werden.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="09" title="Inhalte & Rechtliches">
|
||||||
|
Der Auftraggeber ist allein verantwortlich für Inhalte, rechtliche
|
||||||
|
Konformität (DSGVO, Urheberrecht etc.) sowie bereitgestellte Daten.
|
||||||
|
Der Auftragnehmer übernimmt keine rechtliche Prüfung.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="10" title="Vergütung & Zahlungsverzug">
|
||||||
|
Alle Preise netto zzgl. MwSt. Rechnungen sind innerhalb von 7 Tagen
|
||||||
|
fällig. Bei Zahlungsverzug ist der Auftragnehmer berechtigt,
|
||||||
|
Leistungen auszusetzen, Systeme offline zu nehmen oder laufende
|
||||||
|
Arbeiten zu stoppen.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="11" title="Kündigung laufender Leistungen">
|
||||||
|
Laufende Leistungen (z. B. Hosting & Betrieb) können mit einer Frist
|
||||||
|
von 4 Wochen zum Monatsende gekündigt werden, sofern nichts anderes
|
||||||
|
vereinbart ist.
|
||||||
|
</AGBSection>
|
||||||
|
|
||||||
|
<AGBSection index="12" title="Schlussbestimmungen">
|
||||||
|
Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist
|
||||||
|
der Sitz des Auftragnehmers. Sollte eine Bestimmung unwirksam sein,
|
||||||
|
bleibt die Wirksamkeit der übrigen Regelungen unberührt.
|
||||||
|
</AGBSection>
|
||||||
|
</PDFView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mode === "full") {
|
||||||
|
return (
|
||||||
|
<SimpleLayout
|
||||||
|
companyData={companyData}
|
||||||
|
bankData={bankData}
|
||||||
|
footerLogo={footerLogo}
|
||||||
|
icon={headerIcon}
|
||||||
|
pageNumber="10"
|
||||||
|
showPageNumber={false}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</SimpleLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PDFPage size="A4" style={pdfStyles.page}>
|
||||||
|
<FoldingMarks />
|
||||||
|
<Header icon={headerIcon} showAddress={false} />
|
||||||
|
{content}
|
||||||
|
<Footer
|
||||||
|
logo={footerLogo}
|
||||||
|
companyData={companyData}
|
||||||
|
bankData={bankData}
|
||||||
|
showDetails={false}
|
||||||
|
showPageNumber={false}
|
||||||
|
/>
|
||||||
|
</PDFPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
79
packages/pdf-library/src/components/CombinedQuotePDF.tsx
Normal file
79
packages/pdf-library/src/components/CombinedQuotePDF.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Document as PDFDocument } from "@react-pdf/renderer";
|
||||||
|
import { EstimationPDF } from "./EstimationPDF.js";
|
||||||
|
import { AgbsPDF } from "./AgbsPDF.js";
|
||||||
|
import { SimpleLayout } from "./pdf/SimpleLayout.js";
|
||||||
|
import { ClosingModule } from "./pdf/modules/CommonModules.js";
|
||||||
|
|
||||||
|
interface CombinedProps {
|
||||||
|
estimationProps: any;
|
||||||
|
showAgbs?: boolean;
|
||||||
|
techDetails?: any[];
|
||||||
|
principles?: any[];
|
||||||
|
maintenanceDetails?: any[];
|
||||||
|
standardsDetails?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CombinedQuotePDF = ({
|
||||||
|
estimationProps,
|
||||||
|
showAgbs = true,
|
||||||
|
techDetails,
|
||||||
|
principles,
|
||||||
|
maintenanceDetails,
|
||||||
|
standardsDetails,
|
||||||
|
mode = "full",
|
||||||
|
}: CombinedProps & { mode?: "estimation" | "full" }) => {
|
||||||
|
const date = new Date().toLocaleDateString("de-DE", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const companyData = {
|
||||||
|
name: "Marc Mintel",
|
||||||
|
address1: "Georg-Meistermann-Straße 7",
|
||||||
|
address2: "54586 Schüller",
|
||||||
|
ustId: "DE367588065",
|
||||||
|
};
|
||||||
|
|
||||||
|
const bankData = {
|
||||||
|
name: "N26",
|
||||||
|
bic: "NTSBDEB1XXX",
|
||||||
|
iban: "DE50 1001 1001 2620 4328 65",
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutProps = {
|
||||||
|
date,
|
||||||
|
icon: estimationProps.headerIcon,
|
||||||
|
footerLogo: estimationProps.footerLogo,
|
||||||
|
companyData,
|
||||||
|
bankData,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PDFDocument
|
||||||
|
title={`Mintel - ${estimationProps.state.companyName || estimationProps.state.name}`}
|
||||||
|
>
|
||||||
|
<EstimationPDF
|
||||||
|
{...estimationProps}
|
||||||
|
mode={mode}
|
||||||
|
techDetails={techDetails}
|
||||||
|
principles={principles}
|
||||||
|
maintenanceDetails={maintenanceDetails}
|
||||||
|
standardsDetails={standardsDetails}
|
||||||
|
/>
|
||||||
|
{showAgbs && (
|
||||||
|
<AgbsPDF
|
||||||
|
mode={mode}
|
||||||
|
headerIcon={estimationProps.headerIcon}
|
||||||
|
footerLogo={estimationProps.footerLogo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SimpleLayout {...layoutProps} pageNumber="END" showPageNumber={false}>
|
||||||
|
<ClosingModule />
|
||||||
|
</SimpleLayout>
|
||||||
|
</PDFDocument>
|
||||||
|
);
|
||||||
|
};
|
||||||
95
packages/pdf-library/src/components/EstimationPDF.tsx
Normal file
95
packages/pdf-library/src/components/EstimationPDF.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Page as PDFPage, Document as PDFDocument } from "@react-pdf/renderer";
|
||||||
|
import { pdfStyles } from "./pdf/SharedUI.js";
|
||||||
|
import { SimpleLayout } from "./pdf/SimpleLayout.js";
|
||||||
|
|
||||||
|
// Modules
|
||||||
|
import { FrontPageModule } from "./pdf/modules/FrontPageModule.js";
|
||||||
|
import { BriefingModule } from "./pdf/modules/BriefingModule.js";
|
||||||
|
import { SitemapModule } from "./pdf/modules/SitemapModule.js";
|
||||||
|
import { EstimationModule } from "./pdf/modules/EstimationModule.js";
|
||||||
|
import { TransparenzModule } from "./pdf/modules/TransparenzModule.js";
|
||||||
|
import { ClosingModule } from "./pdf/modules/CommonModules.js";
|
||||||
|
|
||||||
|
import { calculatePositions } from "../logic/pricing/calculator.js";
|
||||||
|
|
||||||
|
interface PDFProps {
|
||||||
|
state: any;
|
||||||
|
totalPrice: number;
|
||||||
|
monthlyPrice?: number;
|
||||||
|
totalPagesCount?: number;
|
||||||
|
pricing: any;
|
||||||
|
headerIcon?: string;
|
||||||
|
footerLogo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EstimationPDF = ({
|
||||||
|
state,
|
||||||
|
totalPrice,
|
||||||
|
pricing,
|
||||||
|
headerIcon,
|
||||||
|
footerLogo,
|
||||||
|
}: PDFProps) => {
|
||||||
|
const date = new Date().toLocaleDateString("de-DE", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const positions = calculatePositions(state, pricing);
|
||||||
|
|
||||||
|
const companyData = {
|
||||||
|
name: "Marc Mintel",
|
||||||
|
address1: "Georg-Meistermann-Straße 7",
|
||||||
|
address2: "54586 Schüller",
|
||||||
|
ustId: "DE367588065",
|
||||||
|
};
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
state,
|
||||||
|
date,
|
||||||
|
icon: headerIcon,
|
||||||
|
footerLogo,
|
||||||
|
companyData,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pageCounter = 1;
|
||||||
|
const getPageNum = () => (pageCounter++).toString().padStart(2, "0");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PDFDocument title={`Angebot - ${state.companyName || "Projekt"}`}>
|
||||||
|
<PDFPage size="A4" style={pdfStyles.titlePage}>
|
||||||
|
<FrontPageModule state={state} headerIcon={headerIcon} date={date} />
|
||||||
|
</PDFPage>
|
||||||
|
|
||||||
|
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||||
|
<BriefingModule state={state} />
|
||||||
|
</SimpleLayout>
|
||||||
|
|
||||||
|
{state.sitemap && state.sitemap.length > 0 && (
|
||||||
|
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||||
|
<SitemapModule state={state} />
|
||||||
|
</SimpleLayout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||||
|
<EstimationModule
|
||||||
|
state={state}
|
||||||
|
positions={positions}
|
||||||
|
totalPrice={totalPrice}
|
||||||
|
date={date}
|
||||||
|
/>
|
||||||
|
</SimpleLayout>
|
||||||
|
|
||||||
|
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||||
|
<TransparenzModule pricing={pricing} />
|
||||||
|
</SimpleLayout>
|
||||||
|
|
||||||
|
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||||
|
<ClosingModule />
|
||||||
|
</SimpleLayout>
|
||||||
|
</PDFDocument>
|
||||||
|
);
|
||||||
|
};
|
||||||
55
packages/pdf-library/src/components/pdf/DINLayout.tsx
Normal file
55
packages/pdf-library/src/components/pdf/DINLayout.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Page as PDFPage } from '@react-pdf/renderer';
|
||||||
|
import { FoldingMarks, Header, Footer, pdfStyles } from './SharedUI';
|
||||||
|
|
||||||
|
interface DINLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
sender?: string;
|
||||||
|
recipient?: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
taxId?: string;
|
||||||
|
};
|
||||||
|
icon?: string;
|
||||||
|
footerLogo?: string;
|
||||||
|
companyData: any;
|
||||||
|
bankData: any;
|
||||||
|
showAddress?: boolean;
|
||||||
|
showFooterDetails?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DINLayout = ({
|
||||||
|
children,
|
||||||
|
sender,
|
||||||
|
recipient,
|
||||||
|
icon,
|
||||||
|
footerLogo,
|
||||||
|
companyData,
|
||||||
|
bankData,
|
||||||
|
showAddress = true,
|
||||||
|
showFooterDetails = true
|
||||||
|
}: DINLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<PDFPage size="A4" style={pdfStyles.page}>
|
||||||
|
<FoldingMarks />
|
||||||
|
<Header
|
||||||
|
sender={sender}
|
||||||
|
recipient={recipient}
|
||||||
|
icon={icon}
|
||||||
|
showAddress={showAddress}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
<Footer
|
||||||
|
logo={footerLogo}
|
||||||
|
companyData={companyData}
|
||||||
|
bankData={bankData}
|
||||||
|
showDetails={showFooterDetails}
|
||||||
|
/>
|
||||||
|
</PDFPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
728
packages/pdf-library/src/components/pdf/SharedUI.tsx
Normal file
728
packages/pdf-library/src/components/pdf/SharedUI.tsx
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
View as PDFView,
|
||||||
|
Text as PDFText,
|
||||||
|
StyleSheet,
|
||||||
|
Image as PDFImage,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
|
||||||
|
// INDUSTRIAL DESIGN SYSTEM TOKENS
|
||||||
|
export const COLORS = {
|
||||||
|
CHARCOAL: "#0f172a", // Slate 900
|
||||||
|
TEXT_MAIN: "#334155", // Slate 700
|
||||||
|
TEXT_DIM: "#64748b", // Slate 500
|
||||||
|
TEXT_LIGHT: "#94a3b8", // Slate 400
|
||||||
|
DIVIDER: "#cbd5e1", // Slate 300
|
||||||
|
GRID: "#f1f5f9", // Slate 100
|
||||||
|
BLUEPRINT: "#e2e8f0", // Slate 200
|
||||||
|
WHITE: "#ffffff",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FONT_SIZES = {
|
||||||
|
HERO: 24, // Main Page Titles
|
||||||
|
HEADING: 14, // Section Headers
|
||||||
|
BODY: 11, // Standard Content
|
||||||
|
LABEL: 10, // Bold Labels / Keys
|
||||||
|
SMALL: 9, // Descriptions / Footnotes
|
||||||
|
TINY: 8, // Metadata / Unit prices
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mintel Industrial Glyphs (strictly 1px stroke, 12x12px grid)
|
||||||
|
export const IndustrialGlyph = ({
|
||||||
|
type,
|
||||||
|
color = COLORS.TEXT_LIGHT,
|
||||||
|
size = 12,
|
||||||
|
}: {
|
||||||
|
type: string;
|
||||||
|
color?: string;
|
||||||
|
size?: number;
|
||||||
|
}) => {
|
||||||
|
const stroke = 1;
|
||||||
|
const scale = size / 12;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "base": // Skeletal cube base
|
||||||
|
return (
|
||||||
|
<PDFView style={{ width: size, height: size, position: "relative" }}>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 2 * scale,
|
||||||
|
left: 2 * scale,
|
||||||
|
width: 8 * scale,
|
||||||
|
height: 8 * scale,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 4 * scale,
|
||||||
|
height: 4 * scale,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
case "pages": // Layered rectangles
|
||||||
|
return (
|
||||||
|
<PDFView style={{ width: size, height: size, position: "relative" }}>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 3 * scale,
|
||||||
|
left: 3 * scale,
|
||||||
|
width: 6 * scale,
|
||||||
|
height: 8 * scale,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 6 * scale,
|
||||||
|
height: 8 * scale,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
case "modules": // Four small squares grid
|
||||||
|
return (
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2 * scale,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: 4 * scale,
|
||||||
|
height: 4 * scale,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: 4 * scale,
|
||||||
|
height: 4 * scale,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: 4 * scale,
|
||||||
|
height: 4 * scale,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: 4 * scale,
|
||||||
|
height: 4 * scale,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
case "logic": // Diamond with center point
|
||||||
|
return (
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: 8 * scale,
|
||||||
|
height: 8 * scale,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: color,
|
||||||
|
transform: "rotate(45deg)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: 2 * scale,
|
||||||
|
height: 2 * scale,
|
||||||
|
backgroundColor: color,
|
||||||
|
position: "absolute",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
case "interface": // Three horizontal lines of varying length
|
||||||
|
return (
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 2 * scale,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: 10 * scale,
|
||||||
|
height: stroke,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{ width: 6 * scale, height: stroke, backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: 10 * scale,
|
||||||
|
height: stroke,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
case "management": // Framed grid
|
||||||
|
return (
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: color,
|
||||||
|
padding: 1 * scale,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 2 * scale,
|
||||||
|
backgroundColor: color,
|
||||||
|
marginBottom: 1 * scale,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 2 * scale,
|
||||||
|
backgroundColor: color,
|
||||||
|
marginBottom: 1 * scale,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{ width: "100%", height: 2 * scale, backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
case "reveal": // Ascending bars
|
||||||
|
return (
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: 1 * scale,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: 2 * scale,
|
||||||
|
height: 4 * scale,
|
||||||
|
backgroundColor: color,
|
||||||
|
opacity: 0.4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: 2 * scale,
|
||||||
|
height: 7 * scale,
|
||||||
|
backgroundColor: color,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: 2 * scale,
|
||||||
|
height: 10 * scale,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
case "maintenance": // Circle with vertical notch
|
||||||
|
return (
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: 6 * scale,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: color,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: stroke,
|
||||||
|
height: 4 * scale,
|
||||||
|
backgroundColor: color,
|
||||||
|
marginTop: 1 * scale,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderWidth: stroke,
|
||||||
|
borderColor: COLORS.BLUEPRINT,
|
||||||
|
borderStyle: "dashed",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pdfStyles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
paddingTop: 45, // DIN 5008
|
||||||
|
paddingLeft: 70, // ~25mm
|
||||||
|
paddingRight: 57, // ~20mm
|
||||||
|
paddingBottom: 80, // Safe buffer for absolute footer
|
||||||
|
backgroundColor: COLORS.WHITE,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
},
|
||||||
|
titlePage: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: COLORS.WHITE,
|
||||||
|
fontFamily: "Helvetica",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
marginBottom: 20,
|
||||||
|
minHeight: 120,
|
||||||
|
},
|
||||||
|
addressBlock: {
|
||||||
|
width: "55%",
|
||||||
|
marginTop: 45,
|
||||||
|
},
|
||||||
|
senderLine: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
textDecoration: "underline",
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
recipientAddress: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
brandLogoContainer: {
|
||||||
|
width: "40%",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
},
|
||||||
|
brandIconContainer: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
brandIconText: {
|
||||||
|
color: COLORS.WHITE,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
titleInfo: {
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
mainTitle: {
|
||||||
|
fontSize: FONT_SIZES.HEADING,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 4,
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
subTitle: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
marginTop: 2,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: FONT_SIZES.LABEL,
|
||||||
|
fontWeight: "bold",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
color: COLORS.TEXT_LIGHT,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 32,
|
||||||
|
left: 70,
|
||||||
|
right: 57,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: COLORS.GRID,
|
||||||
|
paddingTop: 16,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
footerColumn: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
footerLogo: {
|
||||||
|
height: 20,
|
||||||
|
width: "auto",
|
||||||
|
objectFit: "contain",
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
color: COLORS.TEXT_LIGHT,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
asymmetryContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 32,
|
||||||
|
},
|
||||||
|
asymmetryLeft: {
|
||||||
|
width: "32%",
|
||||||
|
},
|
||||||
|
asymmetryRight: {
|
||||||
|
width: "63%",
|
||||||
|
},
|
||||||
|
specRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: COLORS.GRID,
|
||||||
|
},
|
||||||
|
specLabel: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.TEXT_LIGHT,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
specValue: {
|
||||||
|
fontSize: FONT_SIZES.SMALL,
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
blueprintBox: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.GRID,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: "#fafafa",
|
||||||
|
},
|
||||||
|
footerLabel: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
},
|
||||||
|
pageNumber: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
color: COLORS.DIVIDER,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginTop: 8,
|
||||||
|
textAlign: "right",
|
||||||
|
},
|
||||||
|
foldingMark: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 20,
|
||||||
|
width: 10,
|
||||||
|
borderTopWidth: 0.5,
|
||||||
|
borderTopColor: COLORS.DIVIDER,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
width: "100%",
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: COLORS.DIVIDER,
|
||||||
|
marginVertical: 12,
|
||||||
|
},
|
||||||
|
industrialListItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
industrialBulletBox: {
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: COLORS.DIVIDER,
|
||||||
|
marginRight: 8,
|
||||||
|
marginTop: 5,
|
||||||
|
},
|
||||||
|
industrialTitle: {
|
||||||
|
fontSize: FONT_SIZES.HERO,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
marginBottom: 6,
|
||||||
|
letterSpacing: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IndustrialListItem = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={pdfStyles.industrialListItem}>
|
||||||
|
<PDFView style={pdfStyles.industrialBulletBox} />
|
||||||
|
{children}
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Divider = ({ style = {} }: { style?: any }) => (
|
||||||
|
<PDFView style={[pdfStyles.divider, style]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FoldingMarks = () => (
|
||||||
|
<>
|
||||||
|
<PDFView style={[pdfStyles.foldingMark, { top: 297.6 }]} fixed />
|
||||||
|
<PDFView style={[pdfStyles.foldingMark, { top: 420.9, width: 15 }]} fixed />
|
||||||
|
<PDFView style={[pdfStyles.foldingMark, { top: 595.3 }]} fixed />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Footer = ({
|
||||||
|
logo,
|
||||||
|
companyData,
|
||||||
|
bankData,
|
||||||
|
showDetails = true,
|
||||||
|
showPageNumber = true,
|
||||||
|
}: {
|
||||||
|
logo?: string;
|
||||||
|
companyData: any;
|
||||||
|
bankData?: any;
|
||||||
|
showDetails?: boolean;
|
||||||
|
showPageNumber?: boolean;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={pdfStyles.footer}>
|
||||||
|
<PDFView style={pdfStyles.footerColumn}>
|
||||||
|
{logo ? (
|
||||||
|
<PDFImage src={logo} style={pdfStyles.footerLogo} />
|
||||||
|
) : (
|
||||||
|
<PDFText style={{ fontSize: 12, fontWeight: "bold", marginBottom: 8 }}>
|
||||||
|
marc mintel
|
||||||
|
</PDFText>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
{showDetails && (
|
||||||
|
<>
|
||||||
|
<PDFView style={pdfStyles.footerColumn}>
|
||||||
|
<PDFText style={pdfStyles.footerText}>
|
||||||
|
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>
|
||||||
|
{"\n"}
|
||||||
|
{companyData.address1}
|
||||||
|
{"\n"}
|
||||||
|
{companyData.address2}
|
||||||
|
{"\n"}UST: {companyData.ustId}
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
|
||||||
|
{showPageNumber && (
|
||||||
|
<PDFText
|
||||||
|
style={pdfStyles.pageNumber}
|
||||||
|
render={({ pageNumber, totalPages }) =>
|
||||||
|
`${pageNumber} / ${totalPages}`
|
||||||
|
}
|
||||||
|
fixed
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!showDetails && (
|
||||||
|
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
|
||||||
|
{showPageNumber && (
|
||||||
|
<PDFText
|
||||||
|
style={pdfStyles.pageNumber}
|
||||||
|
render={({ pageNumber, totalPages }) =>
|
||||||
|
`${pageNumber} / ${totalPages}`
|
||||||
|
}
|
||||||
|
fixed
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Header = ({
|
||||||
|
sender,
|
||||||
|
recipient,
|
||||||
|
icon,
|
||||||
|
showAddress = true,
|
||||||
|
}: {
|
||||||
|
sender?: string;
|
||||||
|
recipient?: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
email?: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
taxId?: string;
|
||||||
|
};
|
||||||
|
icon?: string;
|
||||||
|
showAddress?: boolean;
|
||||||
|
}) => (
|
||||||
|
<PDFView
|
||||||
|
style={[
|
||||||
|
pdfStyles.header,
|
||||||
|
showAddress ? {} : { minHeight: 40, marginBottom: 0 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<PDFView style={pdfStyles.addressBlock}>
|
||||||
|
{showAddress && sender && (
|
||||||
|
<>
|
||||||
|
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
|
||||||
|
{recipient && (
|
||||||
|
<PDFView style={pdfStyles.recipientAddress}>
|
||||||
|
<PDFText style={{ fontWeight: "bold" }}>
|
||||||
|
{recipient.title}
|
||||||
|
</PDFText>
|
||||||
|
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
|
||||||
|
{recipient.address && <PDFText>{recipient.address}</PDFText>}
|
||||||
|
{recipient.phone && <PDFText>{recipient.phone}</PDFText>}
|
||||||
|
{recipient.email && <PDFText>{recipient.email}</PDFText>}
|
||||||
|
{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}
|
||||||
|
</PDFView>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={pdfStyles.brandLogoContainer}>
|
||||||
|
<PDFView style={pdfStyles.brandIconContainer}>
|
||||||
|
{icon ? (
|
||||||
|
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
|
||||||
|
) : (
|
||||||
|
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DocumentTitle = ({
|
||||||
|
title,
|
||||||
|
subLines,
|
||||||
|
isHero = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subLines?: string[];
|
||||||
|
isHero?: boolean;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={pdfStyles.titleInfo}>
|
||||||
|
<PDFText
|
||||||
|
style={[
|
||||||
|
pdfStyles.mainTitle,
|
||||||
|
{ fontSize: isHero ? FONT_SIZES.HERO : FONT_SIZES.HEADING },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</PDFText>
|
||||||
|
{subLines?.map((line, i) => (
|
||||||
|
<PDFText
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
pdfStyles.subTitle,
|
||||||
|
i === 1 ? { fontWeight: "bold", color: COLORS.CHARCOAL } : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</PDFText>
|
||||||
|
))}
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TechnicalSpec = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={pdfStyles.specRow}>
|
||||||
|
<PDFText style={pdfStyles.specLabel}>{label}</PDFText>
|
||||||
|
<PDFText style={pdfStyles.specValue}>{value}</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AsymmetryView = ({
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
style = {},
|
||||||
|
}: {
|
||||||
|
left: React.ReactNode;
|
||||||
|
right: React.ReactNode;
|
||||||
|
style?: any;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={[pdfStyles.asymmetryContainer, style]}>
|
||||||
|
<PDFView style={pdfStyles.asymmetryLeft}>{left}</PDFView>
|
||||||
|
<PDFView style={pdfStyles.asymmetryRight}>{right}</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IndustrialCard = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
style = {},
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
style?: any;
|
||||||
|
}) => (
|
||||||
|
<PDFView style={[pdfStyles.blueprintBox, { marginBottom: 12 }, style]}>
|
||||||
|
<PDFText
|
||||||
|
style={{
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.TEXT_LIGHT,
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 6,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</PDFText>
|
||||||
|
{children}
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
67
packages/pdf-library/src/components/pdf/SimpleLayout.tsx
Normal file
67
packages/pdf-library/src/components/pdf/SimpleLayout.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Page as PDFPage, View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
|
||||||
|
import { Header, Footer, pdfStyles } from './SharedUI.js';
|
||||||
|
|
||||||
|
const simpleStyles = StyleSheet.create({
|
||||||
|
industrialPage: {
|
||||||
|
padding: 30,
|
||||||
|
paddingTop: 20,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
industrialNumber: {
|
||||||
|
fontSize: 60,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#f1f5f9',
|
||||||
|
position: 'absolute',
|
||||||
|
top: -10,
|
||||||
|
right: 0,
|
||||||
|
zIndex: -1,
|
||||||
|
},
|
||||||
|
industrialSection: {
|
||||||
|
marginTop: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SimpleLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
pageNumber?: string;
|
||||||
|
icon?: string;
|
||||||
|
footerLogo?: string;
|
||||||
|
companyData: any;
|
||||||
|
bankData?: any;
|
||||||
|
showPageNumber?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SimpleLayout = ({
|
||||||
|
children,
|
||||||
|
pageNumber,
|
||||||
|
icon,
|
||||||
|
footerLogo,
|
||||||
|
companyData,
|
||||||
|
bankData,
|
||||||
|
showPageNumber = true
|
||||||
|
}: SimpleLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<PDFPage size="A4" style={[pdfStyles.page, simpleStyles.industrialPage]}>
|
||||||
|
<Header icon={icon} showAddress={false} />
|
||||||
|
{pageNumber && <PDFText style={simpleStyles.industrialNumber}>{pageNumber}</PDFText>}
|
||||||
|
<PDFView style={simpleStyles.industrialSection}>
|
||||||
|
<PDFView style={{ width: '100%' }}>
|
||||||
|
{children}
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
<Footer
|
||||||
|
logo={footerLogo}
|
||||||
|
companyData={companyData}
|
||||||
|
bankData={bankData}
|
||||||
|
showDetails={false}
|
||||||
|
showPageNumber={showPageNumber}
|
||||||
|
/>
|
||||||
|
</PDFPage>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
View as PDFView,
|
||||||
|
Text as PDFText,
|
||||||
|
StyleSheet,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import {
|
||||||
|
DocumentTitle,
|
||||||
|
IndustrialListItem,
|
||||||
|
IndustrialCard,
|
||||||
|
Divider,
|
||||||
|
COLORS,
|
||||||
|
FONT_SIZES,
|
||||||
|
} from "../SharedUI";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
industrialTextLead: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.TEXT_MAIN,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
industrialText: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
industrialGrid2: { flexDirection: "row" },
|
||||||
|
industrialCol: { width: "46%" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AboutModule = () => (
|
||||||
|
<>
|
||||||
|
<DocumentTitle
|
||||||
|
title="Expertise & Profil"
|
||||||
|
subLines={["Entwicklung & Technischer Partner für den Mittelstand"]}
|
||||||
|
isHero={true}
|
||||||
|
/>
|
||||||
|
<Divider style={{ marginVertical: 16, backgroundColor: COLORS.GRID }} />
|
||||||
|
|
||||||
|
<PDFView style={{ marginTop: 24 }}>
|
||||||
|
<PDFText style={styles.industrialTextLead}>
|
||||||
|
Begleitung mittelständischer Unternehmen und Agenturen bei der
|
||||||
|
Realisierung anspruchsvoller Web-Projekte. Als Senior Software Developer
|
||||||
|
mit over 15 Jahren Erfahrung wird das gesamte technische Spektrum
|
||||||
|
abgedeckt – von der Architektur bis zum fertigen Produkt.
|
||||||
|
</PDFText>
|
||||||
|
|
||||||
|
<PDFView style={[styles.industrialGrid2, { marginTop: 20 }]}>
|
||||||
|
<PDFView style={[styles.industrialCol, { marginRight: "8%" }]}>
|
||||||
|
<PDFText
|
||||||
|
style={[
|
||||||
|
styles.industrialText,
|
||||||
|
{ fontWeight: "bold", color: COLORS.CHARCOAL, marginBottom: 8 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Erfahrung & Substanz
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.industrialText}>
|
||||||
|
Der Werdegang umfasst alle Ebenen der Webentwicklung: von der
|
||||||
|
Teamleitung in Kreativagenturen bis zur Softwareentwicklung für
|
||||||
|
internationale Konzerne.
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.industrialText}>
|
||||||
|
Die Kenntnis komplexer Enterprise-Systeme wird mit der Agilität
|
||||||
|
kombiniert, die im Mittelstand gefordert ist. Dieses Wissen
|
||||||
|
ermöglicht den Bau von Lösungen, die technologisch auf Augenhöhe mit
|
||||||
|
Konzern-Standards sind, jedoch ohne unnötigen bürokratischen
|
||||||
|
Overhead auskommen.
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
|
||||||
|
<PDFView style={styles.industrialCol}>
|
||||||
|
<PDFText
|
||||||
|
style={[
|
||||||
|
styles.industrialText,
|
||||||
|
{ fontWeight: "bold", color: COLORS.CHARCOAL, marginBottom: 8 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Fokus Einzelentwicklung
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.industrialText}>
|
||||||
|
Die Umsetzung erfolgt bewusst als spezialisierter Einzelentwickler.
|
||||||
|
Dies garantiert maximale Geschwindigkeit, direkte Kommunikationswege
|
||||||
|
und volle technologische Verantwortung.
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.industrialText}>
|
||||||
|
Als direkter technischer Sparringspartner bleibt die Codebasis von
|
||||||
|
der ersten bis zur letzten Zeile transparent und wartbar. Diese
|
||||||
|
Unmittelbarkeit stellt sicher, dass Ergebnisse sowohl technisch
|
||||||
|
sauber als auch wirtschaftlich sinnvoll realisiert werden.
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
marginTop: 32,
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: COLORS.GRID,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PDFText
|
||||||
|
style={[
|
||||||
|
styles.industrialText,
|
||||||
|
{ fontWeight: "bold", color: COLORS.CHARCOAL, marginBottom: 4 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Infrastruktur & Souveränität
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.industrialText}>
|
||||||
|
Es wird keine instabile Prototyp-Software geliefert, sondern
|
||||||
|
produktionsreife Systeme, die technisch skalierbar bleiben. Die
|
||||||
|
Codebasis folgt modernen Standards – bei wachsenden Ansprüchen oder
|
||||||
|
dem Wechsel zu einer Agentur kann der Quellcode jederzeit nahtlos
|
||||||
|
übernommen und weitergeführt werden.
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CrossSellModule = ({ state }: any) => {
|
||||||
|
const isWebsite = state.projectType === "website";
|
||||||
|
const title = isWebsite ? "Weitere Potenziale" : "Websites & Ökosysteme";
|
||||||
|
const subtitle = isWebsite
|
||||||
|
? "Automatisierung und Prozessoptimierung"
|
||||||
|
: "Technische Infrastruktur ohne Kompromisse";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DocumentTitle title={title} subLines={[subtitle]} isHero={true} />
|
||||||
|
<Divider style={{ marginVertical: 16, backgroundColor: COLORS.GRID }} />
|
||||||
|
<PDFView style={[styles.industrialGrid2, { marginTop: 16 }]}>
|
||||||
|
{isWebsite ? (
|
||||||
|
<>
|
||||||
|
<PDFView style={[styles.industrialCol, { marginRight: "8%" }]}>
|
||||||
|
<PDFText style={styles.industrialTextLead}>
|
||||||
|
Über die klassische Webpräsenz hinaus werden maßgeschneiderte
|
||||||
|
Lösungen zur Automatisierung von Routine-Prozessen angeboten.
|
||||||
|
Dies ermöglicht eine signifikante Effizienzsteigerung im
|
||||||
|
Tagesgeschäft.
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={[styles.industrialText, { fontWeight: "bold" }]}>
|
||||||
|
Keine Abos. Keine komplexen neuen Systeme. Gezielte
|
||||||
|
Zeitersparnis.
|
||||||
|
</PDFText>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: COLORS.GRID,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PDFText
|
||||||
|
style={[
|
||||||
|
styles.industrialText,
|
||||||
|
{
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Individuelle Analyse
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.industrialText}>
|
||||||
|
Spezifische Prozesse werden auf technisches
|
||||||
|
Automatisierungspotenzial untersucht. Das Ergebnis liefert
|
||||||
|
Klarheit über die wirtschaftliche Sinnhaftigkeit einer
|
||||||
|
Umsetzung.
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={styles.industrialCol}>
|
||||||
|
<IndustrialCard title="DOKUMENT-AUTOMATION">
|
||||||
|
<PDFText style={styles.industrialText}>
|
||||||
|
Erstellung von PDF-Angeboten, Berichten oder Protokollen in
|
||||||
|
Sekunden statt Stunden.
|
||||||
|
</PDFText>
|
||||||
|
</IndustrialCard>
|
||||||
|
<IndustrialCard title="EXCEL-LOGIK">
|
||||||
|
<PDFText style={styles.industrialText}>
|
||||||
|
Intelligente Tabellen und automatisierte Auswertungen
|
||||||
|
bestehender Datensätze.
|
||||||
|
</PDFText>
|
||||||
|
</IndustrialCard>
|
||||||
|
<IndustrialCard title="KI-ASSISTENZ">
|
||||||
|
<PDFText style={styles.industrialText}>
|
||||||
|
Effiziente Verarbeitung von analogen Dokumenten oder
|
||||||
|
handschriftlichen Notizen mittels KI.
|
||||||
|
</PDFText>
|
||||||
|
</IndustrialCard>
|
||||||
|
</PDFView>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<PDFView style={{ width: "100%" }}>
|
||||||
|
<PDFText style={styles.industrialTextLead}>
|
||||||
|
Bereitstellung einer stabilen technischen Basis ohne
|
||||||
|
Abhängigkeiten von Baukasten-Systemen oder Agenturen.
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.industrialText}>
|
||||||
|
Entwicklung performanter Frontends und skalierbarer Backends. Die
|
||||||
|
Auslieferung erfolgt als kontrollierbarer und nachhaltiger
|
||||||
|
Quellcode.
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
View as PDFView,
|
||||||
|
Text as PDFText,
|
||||||
|
StyleSheet,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
section: { marginBottom: 24 },
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: FONT_SIZES.LABEL,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: 8,
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
},
|
||||||
|
visionText: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.TEXT_MAIN,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
textAlign: "justify",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BriefingModule = ({ state }: any) => (
|
||||||
|
<>
|
||||||
|
<DocumentTitle title="Projektdetails" isHero={true} />
|
||||||
|
{state.briefingSummary && (
|
||||||
|
<PDFView style={styles.section}>
|
||||||
|
<PDFText style={styles.sectionTitle}>Briefing Analyse</PDFText>
|
||||||
|
<PDFText
|
||||||
|
style={{
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.TEXT_MAIN,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
textAlign: "justify",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state.briefingSummary}
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
)}
|
||||||
|
{state.designVision && (
|
||||||
|
<PDFView
|
||||||
|
style={[
|
||||||
|
styles.section,
|
||||||
|
{
|
||||||
|
padding: 12,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: COLORS.DIVIDER,
|
||||||
|
backgroundColor: COLORS.GRID,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<PDFText
|
||||||
|
style={[
|
||||||
|
styles.sectionTitle,
|
||||||
|
{ color: COLORS.CHARCOAL, marginBottom: 4 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Strategische Vision
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.visionText}>{state.designVision}</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
View as PDFView,
|
||||||
|
Text as PDFText,
|
||||||
|
StyleSheet,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import {
|
||||||
|
DocumentTitle,
|
||||||
|
COLORS,
|
||||||
|
FONT_SIZES,
|
||||||
|
} from "../SharedUI.js";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
section: { marginBottom: 24 },
|
||||||
|
moduleLabel: {
|
||||||
|
fontSize: FONT_SIZES.LABEL,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
moduleDesc: {
|
||||||
|
fontSize: FONT_SIZES.SMALL,
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
ledgerRow: {
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: COLORS.GRID,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
ledgerPrice: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
},
|
||||||
|
ledgerUnit: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
color: COLORS.TEXT_LIGHT,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ClosingModule = () => (
|
||||||
|
<>
|
||||||
|
<DocumentTitle title="Abschluss & Kontakt" isHero={true} />
|
||||||
|
<PDFView style={styles.section}>
|
||||||
|
<PDFText
|
||||||
|
style={[
|
||||||
|
styles.moduleLabel,
|
||||||
|
{ fontSize: FONT_SIZES.HEADING, marginBottom: 12 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Vielen Dank für Ihr Interesse!
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.moduleDesc}>
|
||||||
|
Die aufgeführten Positionen stellen eine detaillierte Schätzung auf
|
||||||
|
Basis unseres aktuellen Stands dar. Sollten sich Anforderungen ändern
|
||||||
|
oder Sie Fragen zu einzelnen Details haben, lassen Sie uns die
|
||||||
|
Positionen gerne gemeinsam besprechen.
|
||||||
|
</PDFText>
|
||||||
|
<PDFView
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: COLORS.GRID,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: COLORS.DIVIDER,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PDFText style={[styles.moduleLabel, { marginBottom: 8 }]}>
|
||||||
|
Haben Sie Fragen?
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={styles.moduleDesc}>
|
||||||
|
Ich erkläre Ihnen gerne noch einmal persönlich, was die technische
|
||||||
|
Umsetzung für Ihr Projekt bedeutet und wie wir die nächsten Schritte
|
||||||
|
gemeinsam gehen können.
|
||||||
|
</PDFText>
|
||||||
|
<PDFView style={{ marginTop: 16 }}>
|
||||||
|
<PDFText style={styles.moduleLabel}>Kontakt:</PDFText>
|
||||||
|
<PDFText
|
||||||
|
style={[
|
||||||
|
styles.moduleDesc,
|
||||||
|
{ color: COLORS.CHARCOAL, fontWeight: "bold" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Marc Mintel – marc@mintel.me
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
View as PDFView,
|
||||||
|
Text as PDFText,
|
||||||
|
StyleSheet,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
table: { marginTop: 12 },
|
||||||
|
tableHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: COLORS.CHARCOAL,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
tableRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: COLORS.GRID,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
colPos: { width: "8%" },
|
||||||
|
colDesc: { width: "62%" },
|
||||||
|
colQty: { width: "10%", textAlign: "center" },
|
||||||
|
colPrice: { width: "20%", textAlign: "right" },
|
||||||
|
headerText: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
},
|
||||||
|
posText: { fontSize: FONT_SIZES.TINY, color: COLORS.TEXT_LIGHT },
|
||||||
|
itemTitle: {
|
||||||
|
fontSize: FONT_SIZES.LABEL,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
itemDesc: {
|
||||||
|
fontSize: FONT_SIZES.SMALL,
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
priceText: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
},
|
||||||
|
summaryContainer: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: COLORS.CHARCOAL,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
summaryRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
paddingVertical: 4,
|
||||||
|
alignItems: "baseline",
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
summaryValue: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
width: 100,
|
||||||
|
textAlign: "right",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
},
|
||||||
|
totalRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
paddingTop: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
borderTopWidth: 2,
|
||||||
|
borderTopColor: COLORS.CHARCOAL,
|
||||||
|
alignItems: "baseline",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EstimationModule = ({
|
||||||
|
state,
|
||||||
|
positions,
|
||||||
|
totalPrice,
|
||||||
|
date,
|
||||||
|
}: any) => (
|
||||||
|
<>
|
||||||
|
<DocumentTitle
|
||||||
|
title="Kostenschätzung"
|
||||||
|
subLines={[
|
||||||
|
`Datum: ${date}`,
|
||||||
|
`Projekt: ${state.projectType === "website" ? "Website" : "Web App"}`,
|
||||||
|
]}
|
||||||
|
isHero={true}
|
||||||
|
/>
|
||||||
|
<PDFView style={styles.table}>
|
||||||
|
<PDFView style={styles.tableHeader}>
|
||||||
|
<PDFText style={[styles.headerText, styles.colPos]}>Pos</PDFText>
|
||||||
|
<PDFText style={[styles.headerText, styles.colDesc]}>
|
||||||
|
Beschreibung
|
||||||
|
</PDFText>
|
||||||
|
<PDFText style={[styles.headerText, styles.colQty]}>Menge</PDFText>
|
||||||
|
<PDFText style={[styles.headerText, styles.colPrice]}>Betrag</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
{positions.map((item: any, i: number) => (
|
||||||
|
<PDFView key={i} style={styles.tableRow} wrap={false}>
|
||||||
|
<PDFText style={[styles.posText, styles.colPos]}>
|
||||||
|
{item.pos.toString().padStart(2, "0")}
|
||||||
|
</PDFText>
|
||||||
|
<PDFView style={styles.colDesc}>
|
||||||
|
<PDFText style={styles.itemTitle}>{item.title}</PDFText>
|
||||||
|
<PDFText style={styles.itemDesc}>
|
||||||
|
{state.positionDescriptions?.[item.title] || item.desc}
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
<PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText>
|
||||||
|
<PDFText style={[styles.priceText, styles.colPrice]}>
|
||||||
|
{item.price > 0
|
||||||
|
? `${item.price.toLocaleString("de-DE")} €`
|
||||||
|
: "n. A."}
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
))}
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={styles.summaryContainer} wrap={false}>
|
||||||
|
<PDFView style={styles.summaryRow}>
|
||||||
|
<PDFText style={styles.summaryLabel}>Nettobetrag</PDFText>
|
||||||
|
<PDFText style={styles.summaryValue}>
|
||||||
|
{totalPrice.toLocaleString("de-DE")} €
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={styles.summaryRow}>
|
||||||
|
<PDFText style={styles.summaryLabel}>Umsatzsteuer (19%)</PDFText>
|
||||||
|
<PDFText style={styles.summaryValue}>
|
||||||
|
{(totalPrice * 0.19).toLocaleString("de-DE")} €
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={styles.totalRow}>
|
||||||
|
<PDFText style={styles.summaryLabel}>Gesamtbetrag (Brutto)</PDFText>
|
||||||
|
<PDFText
|
||||||
|
style={[styles.summaryValue, { fontSize: FONT_SIZES.HEADING }]}
|
||||||
|
>
|
||||||
|
{(totalPrice * 1.19).toLocaleString("de-DE")} €
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
View as PDFView,
|
||||||
|
Text as PDFText,
|
||||||
|
Image as PDFImage,
|
||||||
|
StyleSheet,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import { COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
titlePage: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 60,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: COLORS.WHITE,
|
||||||
|
},
|
||||||
|
titleBrandIcon: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
backgroundColor: COLORS.CHARCOAL,
|
||||||
|
borderRadius: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginBottom: 40,
|
||||||
|
},
|
||||||
|
brandIconText: {
|
||||||
|
fontSize: 40,
|
||||||
|
color: COLORS.WHITE,
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
titleProjectName: {
|
||||||
|
fontSize: FONT_SIZES.HERO,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: "center",
|
||||||
|
maxWidth: "85%",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
},
|
||||||
|
titleDate: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.TEXT_LIGHT,
|
||||||
|
marginTop: 40,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FrontPageModule = ({ state, headerIcon, date }: any) => {
|
||||||
|
const fullTitle = `Digitale Webpräsenz für\n${state.companyName || "Ihr Projekt"}`;
|
||||||
|
const fontSize = fullTitle.length > 60 ? 14 : fullTitle.length > 40 ? 18 : 22;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PDFView style={styles.titlePage}>
|
||||||
|
<PDFView style={styles.titleBrandIcon}>
|
||||||
|
{headerIcon ? (
|
||||||
|
<PDFImage src={headerIcon} style={{ width: 40, height: 40 }} />
|
||||||
|
) : (
|
||||||
|
<PDFText style={styles.brandIconText}>M</PDFText>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
<PDFText style={[styles.titleProjectName, { fontSize }]}>
|
||||||
|
{fullTitle}
|
||||||
|
</PDFText>
|
||||||
|
<PDFView style={{ marginBottom: 40 }} />
|
||||||
|
<PDFText style={styles.titleDate}>{date} | Marc Mintel</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
View as PDFView,
|
||||||
|
Text as PDFText,
|
||||||
|
StyleSheet,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
section: { marginBottom: 32 },
|
||||||
|
intro: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
marginBottom: 24,
|
||||||
|
textAlign: "justify",
|
||||||
|
},
|
||||||
|
sitemapTree: { marginTop: 8 },
|
||||||
|
rootNode: {
|
||||||
|
padding: 12,
|
||||||
|
backgroundColor: COLORS.GRID,
|
||||||
|
marginBottom: 20,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: COLORS.CHARCOAL,
|
||||||
|
},
|
||||||
|
rootTitle: {
|
||||||
|
fontSize: FONT_SIZES.HEADING,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
categorySection: { marginBottom: 20 },
|
||||||
|
categoryHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingBottom: 6,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: COLORS.BLUEPRINT,
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
categoryIcon: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
backgroundColor: COLORS.GRID,
|
||||||
|
borderInlineWidth: 1,
|
||||||
|
borderColor: COLORS.DIVIDER,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
categoryTitle: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
pagesGrid: { flexDirection: "row", flexWrap: "wrap" },
|
||||||
|
pageCard: {
|
||||||
|
width: "48%",
|
||||||
|
marginRight: "2%",
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.GRID,
|
||||||
|
backgroundColor: "#fafafa",
|
||||||
|
},
|
||||||
|
pageTitle: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.TEXT_MAIN,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
pageDesc: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SitemapModule = ({ state }: any) => (
|
||||||
|
<>
|
||||||
|
<DocumentTitle title="Informationsarchitektur" isHero={true} />
|
||||||
|
<PDFView style={styles.section}>
|
||||||
|
<PDFText style={styles.intro}>
|
||||||
|
Die folgende Struktur definiert die logische Hierarchie und
|
||||||
|
Benutzerführung. Sie dient als Bauplan für die technische Umsetzung und
|
||||||
|
stellt sicher, dass alle relevanten Geschäftsbereiche intuitiv
|
||||||
|
auffindbar sind.
|
||||||
|
</PDFText>
|
||||||
|
|
||||||
|
<PDFView style={styles.sitemapTree}>
|
||||||
|
<PDFView style={styles.rootNode}>
|
||||||
|
<PDFText style={styles.rootTitle}>Seitenstruktur</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
|
||||||
|
{state.sitemap?.map((cat: any, i: number) => (
|
||||||
|
<PDFView key={i} style={styles.categorySection} wrap={false}>
|
||||||
|
<PDFView style={styles.categoryHeader}>
|
||||||
|
<PDFView style={styles.categoryIcon} />
|
||||||
|
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
|
||||||
|
<PDFView style={styles.pagesGrid}>
|
||||||
|
{cat.pages.map((p: any, j: number) => (
|
||||||
|
<PDFView
|
||||||
|
key={j}
|
||||||
|
style={[
|
||||||
|
styles.pageCard,
|
||||||
|
j % 2 === 1 ? { marginRight: 0 } : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
|
||||||
|
{p.desc && (
|
||||||
|
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
|
||||||
|
)}
|
||||||
|
</PDFView>
|
||||||
|
))}
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
))}
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
View as PDFView,
|
||||||
|
Text as PDFText,
|
||||||
|
StyleSheet,
|
||||||
|
} from "@react-pdf/renderer";
|
||||||
|
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
section: { marginBottom: 24 },
|
||||||
|
ledgerRow: {
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: COLORS.GRID,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
moduleLabel: {
|
||||||
|
fontSize: FONT_SIZES.LABEL,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
moduleDesc: {
|
||||||
|
fontSize: FONT_SIZES.SMALL,
|
||||||
|
color: COLORS.TEXT_DIM,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
ledgerPrice: {
|
||||||
|
fontSize: FONT_SIZES.BODY,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: COLORS.CHARCOAL,
|
||||||
|
},
|
||||||
|
ledgerUnit: {
|
||||||
|
fontSize: FONT_SIZES.TINY,
|
||||||
|
color: COLORS.TEXT_LIGHT,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TransparenzModule = ({ pricing }: any) => {
|
||||||
|
const sorglosPrice = (pricing.HOSTING_MONTHLY || 250) * 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DocumentTitle title="Preis-Transparenz & Modell" isHero={true} />
|
||||||
|
<PDFView style={styles.section}>
|
||||||
|
<PDFView style={{ borderTopWidth: 1, borderTopColor: COLORS.CHARCOAL }}>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
l: "Fundament",
|
||||||
|
d: "Bereitstellung der techn. Infrastruktur & System-Umgebung.",
|
||||||
|
p: pricing.BASE_WEBSITE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
l: "Einzelseiten",
|
||||||
|
d: "Individuelle Gestaltung, Layout & responsive Struktur.",
|
||||||
|
p: pricing.PAGE,
|
||||||
|
unit: "/ Stk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
l: "Core Features",
|
||||||
|
d: "Geschlossene Datensysteme mit eigener Datenstruktur.",
|
||||||
|
p: pricing.FEATURE,
|
||||||
|
unit: "/ Stk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
l: "Logik & Funktionen",
|
||||||
|
d: "Interaktive Funktions-Bausteine & Prozess-Logik.",
|
||||||
|
p: pricing.FUNCTION,
|
||||||
|
unit: "/ Stk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
l: "Schnittstellen",
|
||||||
|
d: "Synchronisation mit externen Zielsystemen.",
|
||||||
|
p: pricing.API_INTEGRATION,
|
||||||
|
unit: "/ Stk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
l: "Sprachversionen",
|
||||||
|
d: "Skalierung der System-Architektur auf Zweit-Sprachen.",
|
||||||
|
p: "+20%",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
l: "Initial-Pflege",
|
||||||
|
d: "Konvertierung & Aufbereitung von Bestandsdaten.",
|
||||||
|
p: pricing.NEW_DATASET,
|
||||||
|
unit: "/ Stk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
l: "Sorglos Betrieb",
|
||||||
|
d: "Hosting, Instandhaltung, Security & techn. Support.",
|
||||||
|
p: sorglosPrice,
|
||||||
|
unit: "/ Jahr",
|
||||||
|
},
|
||||||
|
].map((item: any, i: number) => (
|
||||||
|
<PDFView key={i} style={styles.ledgerRow}>
|
||||||
|
<PDFView style={{ width: "25%" }}>
|
||||||
|
<PDFText style={styles.moduleLabel}>
|
||||||
|
{item.l.toUpperCase()}
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={{ width: "50%" }}>
|
||||||
|
<PDFText style={styles.moduleDesc}>{item.d}</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
<PDFView style={{ width: "25%", alignItems: "flex-end" }}>
|
||||||
|
<PDFText style={styles.ledgerPrice}>
|
||||||
|
{typeof item.p === "number"
|
||||||
|
? `${item.p.toLocaleString("de-DE")} €`
|
||||||
|
: item.p}
|
||||||
|
{item.unit && (
|
||||||
|
<PDFText style={styles.ledgerUnit}> {item.unit}</PDFText>
|
||||||
|
)}
|
||||||
|
</PDFText>
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
))}
|
||||||
|
</PDFView>
|
||||||
|
</PDFView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
15
packages/pdf-library/src/index.ts
Normal file
15
packages/pdf-library/src/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export * from "./logic/pricing/types.js";
|
||||||
|
export * from "./logic/pricing/constants.js";
|
||||||
|
export * from "./logic/pricing/calculator.js";
|
||||||
|
export * from "./components/EstimationPDF.js";
|
||||||
|
export * from "./components/pdf/SimpleLayout.js";
|
||||||
|
export * from "./components/pdf/SharedUI.js";
|
||||||
|
export * from "./components/pdf/modules/FrontPageModule.js";
|
||||||
|
export * from "./components/pdf/modules/BriefingModule.js";
|
||||||
|
export * from "./components/pdf/modules/SitemapModule.js";
|
||||||
|
export * from "./components/pdf/modules/EstimationModule.js";
|
||||||
|
export * from "./components/pdf/modules/CommonModules.js";
|
||||||
|
export * from "./components/pdf/modules/BrandingModules.js";
|
||||||
|
export * from "./components/pdf/modules/TransparenzModule.js";
|
||||||
|
export * from "./components/AgbsPDF.js";
|
||||||
|
export * from "./components/CombinedQuotePDF.js";
|
||||||
224
packages/pdf-library/src/logic/pricing/calculator.ts
Normal file
224
packages/pdf-library/src/logic/pricing/calculator.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { FormState, Position, Totals } from "./types.js";
|
||||||
|
import {
|
||||||
|
FEATURE_LABELS,
|
||||||
|
FUNCTION_LABELS,
|
||||||
|
API_LABELS,
|
||||||
|
PAGE_LABELS,
|
||||||
|
} from "./constants.js";
|
||||||
|
|
||||||
|
export function calculateTotals(state: FormState, pricing: any): Totals {
|
||||||
|
if (state.projectType !== "website") {
|
||||||
|
return {
|
||||||
|
totalPrice: 0,
|
||||||
|
monthlyPrice: 0,
|
||||||
|
totalPagesCount: 0,
|
||||||
|
totalFeatures: 0,
|
||||||
|
totalFunctions: 0,
|
||||||
|
totalApis: 0,
|
||||||
|
languagesCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sitemapPagesCount =
|
||||||
|
state.sitemap?.reduce(
|
||||||
|
(acc: number, cat: any) => acc + (cat.pages?.length || 0),
|
||||||
|
0,
|
||||||
|
) || 0;
|
||||||
|
const totalPagesCount = Math.max(
|
||||||
|
(state.selectedPages?.length || 0) +
|
||||||
|
(state.otherPages?.length || 0) +
|
||||||
|
(state.otherPagesCount || 0),
|
||||||
|
sitemapPagesCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalFeatures =
|
||||||
|
(state.features?.length || 0) +
|
||||||
|
(state.otherFeatures?.length || 0) +
|
||||||
|
(state.otherFeaturesCount || 0);
|
||||||
|
const totalFunctions =
|
||||||
|
(state.functions?.length || 0) +
|
||||||
|
(state.otherFunctions?.length || 0) +
|
||||||
|
(state.otherFunctionsCount || 0);
|
||||||
|
const totalApis =
|
||||||
|
(state.apiSystems?.length || 0) +
|
||||||
|
(state.otherTech?.length || 0) +
|
||||||
|
(state.otherTechCount || 0);
|
||||||
|
|
||||||
|
let total = pricing.BASE_WEBSITE;
|
||||||
|
total += totalPagesCount * pricing.PAGE;
|
||||||
|
total += totalFeatures * pricing.FEATURE;
|
||||||
|
total += totalFunctions * pricing.FUNCTION;
|
||||||
|
total += totalApis * pricing.API_INTEGRATION;
|
||||||
|
total += (state.newDatasets || 0) * pricing.NEW_DATASET;
|
||||||
|
|
||||||
|
if (state.cmsSetup) {
|
||||||
|
total += Math.max(1, totalFeatures) * pricing.CMS_CONNECTION_PER_FEATURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languagesCount = state.languagesList?.length || 1;
|
||||||
|
if (languagesCount > 1) {
|
||||||
|
total *= 1 + (languagesCount - 1) * 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyPrice =
|
||||||
|
pricing.HOSTING_MONTHLY +
|
||||||
|
(state.storageExpansion || 0) * pricing.STORAGE_EXPANSION_MONTHLY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalPrice: Math.round(total),
|
||||||
|
monthlyPrice: Math.round(monthlyPrice),
|
||||||
|
totalPagesCount,
|
||||||
|
totalFeatures,
|
||||||
|
totalFunctions,
|
||||||
|
totalApis,
|
||||||
|
languagesCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculatePositions(state: FormState, pricing: any): Position[] {
|
||||||
|
const positions: Position[] = [];
|
||||||
|
let pos = 1;
|
||||||
|
|
||||||
|
if (state.projectType === "website") {
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: "Das technische Fundament",
|
||||||
|
desc: "Projekt-Setup, Infrastruktur, Hosting-Bereitstellung, Grundstruktur & Design-Vorlage, technisches SEO-Basics, Analytics.",
|
||||||
|
qty: 1,
|
||||||
|
price: pricing.BASE_WEBSITE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sitemapPagesCount =
|
||||||
|
state.sitemap?.reduce(
|
||||||
|
(acc: number, cat: any) => acc + (cat.pages?.length || 0),
|
||||||
|
0,
|
||||||
|
) || 0;
|
||||||
|
const totalPagesCount = Math.max(
|
||||||
|
(state.selectedPages?.length || 0) +
|
||||||
|
(state.otherPages?.length || 0) +
|
||||||
|
(state.otherPagesCount || 0),
|
||||||
|
sitemapPagesCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allPages = [
|
||||||
|
...(state.selectedPages || []).map((p: string) => PAGE_LABELS[p] || p),
|
||||||
|
...(state.otherPages || []),
|
||||||
|
...(state.sitemap?.flatMap((cat: any) =>
|
||||||
|
cat.pages?.map((p: any) => p.title),
|
||||||
|
) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Deduplicate labels
|
||||||
|
const uniquePages = Array.from(new Set(allPages));
|
||||||
|
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: "Individuelle Seiten",
|
||||||
|
desc: `Gestaltung und Umsetzung von ${totalPagesCount} individuellen Seiten-Layouts (${uniquePages.join(", ")}).`,
|
||||||
|
qty: totalPagesCount,
|
||||||
|
price: totalPagesCount * pricing.PAGE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((state.features?.length || 0) > 0 || (state.otherFeatures?.length || 0) > 0) {
|
||||||
|
const allFeatures = [
|
||||||
|
...(state.features || []).map((f: string) => FEATURE_LABELS[f] || f),
|
||||||
|
...(state.otherFeatures || []),
|
||||||
|
];
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: "System-Module (Features)",
|
||||||
|
desc: `Implementierung funktionaler Bereiche: ${allFeatures.join(", ")}. Inklusive Datenstruktur und Darstellung.`,
|
||||||
|
qty: allFeatures.length,
|
||||||
|
price: allFeatures.length * pricing.FEATURE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((state.functions?.length || 0) > 0 || (state.otherFunctions?.length || 0) > 0) {
|
||||||
|
const allFunctions = [
|
||||||
|
...(state.functions || []).map((f: string) => FUNCTION_LABELS[f] || f),
|
||||||
|
...(state.otherFunctions || []),
|
||||||
|
];
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: "Logik-Funktionen",
|
||||||
|
desc: `Implementierung technischer Logik: ${allFunctions.join(", ")}.`,
|
||||||
|
qty: allFunctions.length,
|
||||||
|
price: allFunctions.length * pricing.FUNCTION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((state.apiSystems?.length || 0) > 0 || (state.otherTech?.length || 0) > 0) {
|
||||||
|
const allApis = [
|
||||||
|
...(state.apiSystems || []).map((a: string) => API_LABELS[a] || a),
|
||||||
|
...(state.otherTech || []),
|
||||||
|
];
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: "Schnittstellen (API)",
|
||||||
|
desc: `Anbindung externer Systeme zur Datensynchronisation: ${allApis.join(", ")}.`,
|
||||||
|
qty: allApis.length,
|
||||||
|
price: allApis.length * pricing.API_INTEGRATION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.cmsSetup) {
|
||||||
|
const totalFeatures =
|
||||||
|
(state.features?.length || 0) +
|
||||||
|
(state.otherFeatures?.length || 0) +
|
||||||
|
(state.otherFeaturesCount || 0);
|
||||||
|
const qty = Math.max(1, totalFeatures);
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: "Inhalts-Verwaltung",
|
||||||
|
desc: "Anbindung der System-Module an das Redaktions-System zur eigenständigen Pflege von Inhalten.",
|
||||||
|
qty: qty,
|
||||||
|
price: qty * pricing.CMS_CONNECTION_PER_FEATURE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.newDatasets > 0) {
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: "Inhaltliche Initial-Pflege",
|
||||||
|
desc: `Manuelle Übernahme und Aufbereitung von ${state.newDatasets} Datensätzen (Produkte, Artikel) in das Zielsystem.`,
|
||||||
|
qty: state.newDatasets,
|
||||||
|
price: state.newDatasets * pricing.NEW_DATASET,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const languagesCount = state.languagesList?.length || 1;
|
||||||
|
if (languagesCount > 1) {
|
||||||
|
const subtotal = positions.reduce((sum, p) => sum + p.price, 0);
|
||||||
|
const factorPrice = subtotal * ((languagesCount - 1) * 0.2);
|
||||||
|
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: "Mehrsprachigkeit",
|
||||||
|
desc: `Erweiterung des Systems auf ${languagesCount} Sprachen (Struktur & Logik).`,
|
||||||
|
qty: languagesCount,
|
||||||
|
price: Math.round(factorPrice),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyRate =
|
||||||
|
pricing.HOSTING_MONTHLY +
|
||||||
|
state.storageExpansion * pricing.STORAGE_EXPANSION_MONTHLY;
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: "Sorglos Betrieb (1 Jahr)",
|
||||||
|
desc: `Inklusive 1 Jahr Sicherung des technischen Betriebs, Hosting, Instandhaltung, Sicherheits-Updates und techn. Support gemäß AGB Punkt 7a.`,
|
||||||
|
qty: 1,
|
||||||
|
price: monthlyRate * 12,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
positions.push({
|
||||||
|
pos: pos++,
|
||||||
|
title: "Web App / Software Entwicklung",
|
||||||
|
desc: "Individuelle Software-Entwicklung nach Aufwand. Abrechnung erfolgt auf Stundenbasis.",
|
||||||
|
qty: 1,
|
||||||
|
price: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
332
packages/pdf-library/src/logic/pricing/constants.ts
Normal file
332
packages/pdf-library/src/logic/pricing/constants.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import { FormState } from "./types.js";
|
||||||
|
|
||||||
|
export const PRICING = {
|
||||||
|
BASE_WEBSITE: 5440, // Updated to match AI prompt requirement in Pass 1
|
||||||
|
PAGE: 600,
|
||||||
|
FEATURE: 1500,
|
||||||
|
FUNCTION: 800,
|
||||||
|
NEW_DATASET: 450,
|
||||||
|
HOSTING_MONTHLY: 250,
|
||||||
|
STORAGE_EXPANSION_MONTHLY: 10,
|
||||||
|
CMS_SETUP: 1500,
|
||||||
|
CMS_CONNECTION_PER_FEATURE: 1500,
|
||||||
|
API_INTEGRATION: 800,
|
||||||
|
APP_HOURLY: 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialState: FormState = {
|
||||||
|
projectType: "website",
|
||||||
|
// Company
|
||||||
|
companyName: "",
|
||||||
|
employeeCount: "",
|
||||||
|
// Existing Presence
|
||||||
|
existingWebsite: "",
|
||||||
|
socialMedia: [],
|
||||||
|
socialMediaUrls: {},
|
||||||
|
existingDomain: "",
|
||||||
|
wishedDomain: "",
|
||||||
|
// Project
|
||||||
|
websiteTopic: "",
|
||||||
|
selectedPages: ["Home"],
|
||||||
|
otherPages: [],
|
||||||
|
otherPagesCount: 0,
|
||||||
|
features: [],
|
||||||
|
otherFeatures: [],
|
||||||
|
otherFeaturesCount: 0,
|
||||||
|
functions: [],
|
||||||
|
otherFunctions: [],
|
||||||
|
otherFunctionsCount: 0,
|
||||||
|
apiSystems: [],
|
||||||
|
otherTech: [],
|
||||||
|
otherTechCount: 0,
|
||||||
|
assets: [],
|
||||||
|
otherAssets: [],
|
||||||
|
otherAssetsCount: 0,
|
||||||
|
newDatasets: 0,
|
||||||
|
cmsSetup: false,
|
||||||
|
storageExpansion: 0,
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
role: "",
|
||||||
|
message: "",
|
||||||
|
sitemapFile: null,
|
||||||
|
contactFiles: [],
|
||||||
|
// Design
|
||||||
|
designVibe: "minimal",
|
||||||
|
colorScheme: ["#ffffff", "#f8fafc", "#0f172a"],
|
||||||
|
references: [],
|
||||||
|
designWishes: "",
|
||||||
|
// Maintenance
|
||||||
|
expectedAdjustments: "low",
|
||||||
|
languagesList: ["Deutsch"],
|
||||||
|
personName: "",
|
||||||
|
// Timeline
|
||||||
|
deadline: "flexible",
|
||||||
|
// Web App specific
|
||||||
|
targetAudience: "internal",
|
||||||
|
userRoles: [],
|
||||||
|
dataSensitivity: "standard",
|
||||||
|
platformType: "web-only",
|
||||||
|
// Meta
|
||||||
|
dontKnows: [],
|
||||||
|
visualStaging: "standard",
|
||||||
|
complexInteractions: "standard",
|
||||||
|
// AI generated / Post-processed
|
||||||
|
briefingSummary: "",
|
||||||
|
designVision: "",
|
||||||
|
positionDescriptions: {},
|
||||||
|
taxId: "",
|
||||||
|
sitemap: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PAGE_SAMPLES = [
|
||||||
|
{ id: "Home", label: "Startseite", desc: "Der erste Eindruck Ihrer Marke." },
|
||||||
|
{ id: "About", label: "Über uns", desc: "Ihre Geschichte und Ihr Team." },
|
||||||
|
{ id: "Services", label: "Leistungen", desc: "Übersicht Ihres Angebots." },
|
||||||
|
{ id: "Contact", label: "Kontakt", desc: "Anlaufstelle für Ihre Kunden." },
|
||||||
|
{
|
||||||
|
id: "Landing",
|
||||||
|
label: "Landingpage",
|
||||||
|
desc: "Optimiert für Marketing-Kampagnen.",
|
||||||
|
},
|
||||||
|
{ id: "Legal", label: "Rechtliches", desc: "Impressum & Datenschutz." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FEATURE_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: "blog_news",
|
||||||
|
label: "Blog / News",
|
||||||
|
desc: "Ein Bereich für aktuelle Beiträge und Neuigkeiten.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "products",
|
||||||
|
label: "Produktbereich",
|
||||||
|
desc: "Katalog Ihrer Leistungen oder Produkte.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "jobs",
|
||||||
|
label: "Karriere / Jobs",
|
||||||
|
desc: "Stellenanzeigen und Bewerbungsoptionen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "refs",
|
||||||
|
label: "Referenzen / Cases",
|
||||||
|
desc: "Präsentation Ihrer Projekte.",
|
||||||
|
},
|
||||||
|
{ id: "events", label: "Events / Termine", desc: "Veranstaltungskalender." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FUNCTION_OPTIONS = [
|
||||||
|
{ id: "search", label: "Suche", desc: "Volltextsuche über alle Inhalte." },
|
||||||
|
{
|
||||||
|
id: "filter",
|
||||||
|
label: "Filter-Systeme",
|
||||||
|
desc: "Kategorisierung und Sortierung.",
|
||||||
|
},
|
||||||
|
{ id: "pdf", label: "PDF-Export", desc: "Automatisierte PDF-Erstellung." },
|
||||||
|
{
|
||||||
|
id: "forms",
|
||||||
|
label: "Individuelle Formular-Logik",
|
||||||
|
desc: "Smarte Validierung & mehrstufige Prozesse.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const API_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: "crm",
|
||||||
|
label: "CRM System",
|
||||||
|
desc: "HubSpot, Salesforce, Pipedrive etc.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "erp",
|
||||||
|
label: "ERP / Warenwirtschaft",
|
||||||
|
desc: "SAP, Microsoft Dynamics, Xentral etc.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "stripe",
|
||||||
|
label: "Stripe / Payment",
|
||||||
|
desc: "Zahlungsabwicklung und Abonnements.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "newsletter",
|
||||||
|
label: "Newsletter / Marketing",
|
||||||
|
desc: "Mailchimp, Brevo, ActiveCampaign etc.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ecommerce",
|
||||||
|
label: "E-Commerce / Shop",
|
||||||
|
desc: "Shopify, WooCommerce, Shopware Sync.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hr",
|
||||||
|
label: "HR / Recruiting",
|
||||||
|
desc: "Personio, Workday, Recruitee etc.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "realestate",
|
||||||
|
label: "Immobilien",
|
||||||
|
desc: "OpenImmo, FlowFact, Immowelt Sync.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "calendar",
|
||||||
|
label: "Termine / Booking",
|
||||||
|
desc: "Calendly, Shore, Doctolib etc.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "social",
|
||||||
|
label: "Social Media Sync",
|
||||||
|
desc: "Automatisierte Posts oder Feeds.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "maps",
|
||||||
|
label: "Google Maps / Places",
|
||||||
|
desc: "Standortsuche und Kartenintegration.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "analytics",
|
||||||
|
label: "Custom Analytics",
|
||||||
|
desc: "Anbindung an spezialisierte Tracking-Tools.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ASSET_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: "existing_website",
|
||||||
|
label: "Bestehende Website",
|
||||||
|
desc: "Inhalte oder Struktur können übernommen werden.",
|
||||||
|
},
|
||||||
|
{ id: "logo", label: "Logo", desc: "Vektordatei Ihres Logos." },
|
||||||
|
{
|
||||||
|
id: "styleguide",
|
||||||
|
label: "Styleguide",
|
||||||
|
desc: "Farben, Schriften, Design-Vorgaben.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "content_concept",
|
||||||
|
label: "Inhalts-Konzept",
|
||||||
|
desc: "Struktur und Texte sind bereits geplant.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "media",
|
||||||
|
label: "Bild/Video-Material",
|
||||||
|
desc: "Professionelles Bildmaterial vorhanden.",
|
||||||
|
},
|
||||||
|
{ id: "icons", label: "Icons", desc: "Eigene Icon-Sets vorhanden." },
|
||||||
|
{
|
||||||
|
id: "illustrations",
|
||||||
|
label: "Illustrationen",
|
||||||
|
desc: "Eigene Illustrationen vorhanden.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fonts",
|
||||||
|
label: "Fonts",
|
||||||
|
desc: "Lizenzen für Hausschriften vorhanden.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DESIGN_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: "minimal",
|
||||||
|
label: "Minimalistisch",
|
||||||
|
desc: "Viel Weißraum, klare Typografie.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bold",
|
||||||
|
label: "Mutig & Laut",
|
||||||
|
desc: "Starke Kontraste, große Schriften.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nature",
|
||||||
|
label: "Natürlich",
|
||||||
|
desc: "Sanfte Erdtöne, organische Formen.",
|
||||||
|
},
|
||||||
|
{ id: "tech", label: "Technisch", desc: "Präzise Linien, dunkle Akzente." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EMPLOYEE_OPTIONS = [
|
||||||
|
{ id: "1-5", label: "1-5 Mitarbeiter" },
|
||||||
|
{ id: "6-20", label: "6-20 Mitarbeiter" },
|
||||||
|
{ id: "21-100", label: "21-100 Mitarbeiter" },
|
||||||
|
{ id: "100+", label: "100+ Mitarbeiter" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SOCIAL_MEDIA_OPTIONS = [
|
||||||
|
{ id: "instagram", label: "Instagram" },
|
||||||
|
{ id: "linkedin", label: "LinkedIn" },
|
||||||
|
{ id: "facebook", label: "Facebook" },
|
||||||
|
{ id: "twitter", label: "Twitter / X" },
|
||||||
|
{ id: "tiktok", label: "TikTok" },
|
||||||
|
{ id: "youtube", label: "YouTube" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VIBE_LABELS: Record<string, string> = {
|
||||||
|
minimal: "Minimalistisch",
|
||||||
|
bold: "Mutig & Laut",
|
||||||
|
nature: "Natürlich",
|
||||||
|
tech: "Technisch",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEADLINE_LABELS: Record<string, string> = {
|
||||||
|
asap: "So schnell wie möglich",
|
||||||
|
"2-3-months": "In 2-3 Monaten",
|
||||||
|
"3-6-months": "In 3-6 Monaten",
|
||||||
|
flexible: "Flexibel",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ASSET_LABELS: Record<string, string> = {
|
||||||
|
existing_website: "Bestehende Website",
|
||||||
|
logo: "Logo",
|
||||||
|
styleguide: "Styleguide",
|
||||||
|
content_concept: "Inhalts-Konzept",
|
||||||
|
media: "Bild/Video-Material",
|
||||||
|
icons: "Icons",
|
||||||
|
illustrations: "Illustrationen",
|
||||||
|
fonts: "Fonts",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FEATURE_LABELS: Record<string, string> = {
|
||||||
|
blog_news: "Blog / News",
|
||||||
|
products: "Produktbereich",
|
||||||
|
jobs: "Karriere / Jobs",
|
||||||
|
refs: "Referenzen / Cases",
|
||||||
|
events: "Events / Termine",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FUNCTION_LABELS: Record<string, string> = {
|
||||||
|
search: "Suche",
|
||||||
|
filter: "Filter-Systeme",
|
||||||
|
pdf: "PDF-Export",
|
||||||
|
forms: "Individuelle Formular-Logik",
|
||||||
|
members: "Mitgliederbereich",
|
||||||
|
calendar: "Event-Kalender",
|
||||||
|
multilang: "Mehrsprachigkeit",
|
||||||
|
chat: "Echtzeit-Chat",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const API_LABELS: Record<string, string> = {
|
||||||
|
crm_erp: "CRM / ERP",
|
||||||
|
payment: "Payment",
|
||||||
|
marketing: "Marketing",
|
||||||
|
ecommerce: "E-Commerce",
|
||||||
|
maps: "Google Maps / Places",
|
||||||
|
social: "Social Media Sync",
|
||||||
|
analytics: "Custom Analytics",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOCIAL_LABELS: Record<string, string> = {
|
||||||
|
instagram: "Instagram",
|
||||||
|
linkedin: "LinkedIn",
|
||||||
|
facebook: "Facebook",
|
||||||
|
twitter: "Twitter / X",
|
||||||
|
tiktok: "TikTok",
|
||||||
|
youtube: "YouTube",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PAGE_LABELS: Record<string, string> = {
|
||||||
|
Home: "Startseite",
|
||||||
|
About: "Über uns",
|
||||||
|
Services: "Leistungen",
|
||||||
|
Contact: "Kontakt",
|
||||||
|
Landing: "Landingpage",
|
||||||
|
Legal: "Impressum & Datenschutz",
|
||||||
|
};
|
||||||
89
packages/pdf-library/src/logic/pricing/types.ts
Normal file
89
packages/pdf-library/src/logic/pricing/types.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
export type ProjectType = 'website' | 'web-app';
|
||||||
|
|
||||||
|
export interface FormState {
|
||||||
|
projectType: ProjectType;
|
||||||
|
// Company
|
||||||
|
companyName: string;
|
||||||
|
employeeCount: string;
|
||||||
|
// Existing Presence
|
||||||
|
existingWebsite: string;
|
||||||
|
socialMedia: string[];
|
||||||
|
socialMediaUrls: Record<string, string>;
|
||||||
|
existingDomain: string;
|
||||||
|
wishedDomain: string;
|
||||||
|
// Project
|
||||||
|
websiteTopic: string;
|
||||||
|
selectedPages: string[];
|
||||||
|
otherPages: string[];
|
||||||
|
otherPagesCount: number;
|
||||||
|
features: string[];
|
||||||
|
otherFeatures: string[];
|
||||||
|
otherFeaturesCount: number;
|
||||||
|
functions: string[];
|
||||||
|
otherFunctions: string[];
|
||||||
|
otherFunctionsCount: number;
|
||||||
|
apiSystems: string[];
|
||||||
|
otherTech: string[];
|
||||||
|
otherTechCount: number;
|
||||||
|
assets: string[];
|
||||||
|
otherAssets: string[];
|
||||||
|
otherAssetsCount: number;
|
||||||
|
newDatasets: number;
|
||||||
|
cmsSetup: boolean;
|
||||||
|
storageExpansion: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
message: string;
|
||||||
|
sitemapFile: any;
|
||||||
|
contactFiles: any[];
|
||||||
|
// Design
|
||||||
|
designVibe: string;
|
||||||
|
colorScheme: string[];
|
||||||
|
references: string[];
|
||||||
|
designWishes: string;
|
||||||
|
// Maintenance
|
||||||
|
expectedAdjustments: string;
|
||||||
|
languagesList: string[];
|
||||||
|
// Timeline
|
||||||
|
deadline: string;
|
||||||
|
// Web App specific
|
||||||
|
targetAudience: string;
|
||||||
|
userRoles: string[];
|
||||||
|
dataSensitivity: string;
|
||||||
|
platformType: string;
|
||||||
|
// Meta
|
||||||
|
dontKnows: string[];
|
||||||
|
visualStaging: string;
|
||||||
|
complexInteractions: string;
|
||||||
|
gridDontKnows?: Record<string, string>;
|
||||||
|
briefingSummary?: string;
|
||||||
|
companyAddress?: string;
|
||||||
|
companyPhone?: string;
|
||||||
|
personName?: string;
|
||||||
|
taxId?: string;
|
||||||
|
designVision?: string;
|
||||||
|
positionDescriptions?: Record<string, string>;
|
||||||
|
sitemap?: {
|
||||||
|
category: string;
|
||||||
|
pages: { title: string; desc: string }[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Position {
|
||||||
|
pos: number;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
qty: number;
|
||||||
|
price: number;
|
||||||
|
isRecurring?: boolean;
|
||||||
|
}
|
||||||
|
export interface Totals {
|
||||||
|
totalPrice: number;
|
||||||
|
monthlyPrice: number;
|
||||||
|
totalPagesCount: number;
|
||||||
|
totalFeatures: number;
|
||||||
|
totalFunctions: number;
|
||||||
|
totalApis: number;
|
||||||
|
languagesCount: number;
|
||||||
|
}
|
||||||
3
packages/pdf-library/src/server.ts
Normal file
3
packages/pdf-library/src/server.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./index.js";
|
||||||
|
export * from "./services/AcquisitionService.js";
|
||||||
|
export * from "./services/PdfEngine.js";
|
||||||
153
packages/pdf-library/src/services/AcquisitionService.ts
Normal file
153
packages/pdf-library/src/services/AcquisitionService.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { CheerioCrawler } from "@crawlee/cheerio";
|
||||||
|
import axios from "axios";
|
||||||
|
import { FileCacheAdapter } from "../utils/cache/FileCacheAdapter.js";
|
||||||
|
import { initialState } from "../logic/pricing/constants.js";
|
||||||
|
import { FormState } from "../logic/pricing/types.js";
|
||||||
|
|
||||||
|
export interface AcquisitionResult {
|
||||||
|
state: FormState;
|
||||||
|
usage: {
|
||||||
|
prompt: number;
|
||||||
|
completion: number;
|
||||||
|
cost: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AcquisitionService {
|
||||||
|
private cache: FileCacheAdapter;
|
||||||
|
private openRouterKey: string;
|
||||||
|
|
||||||
|
constructor(openRouterKey: string) {
|
||||||
|
this.openRouterKey = openRouterKey;
|
||||||
|
this.cache = new FileCacheAdapter({ prefix: "acq_" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async runFullSequence(url: string, briefing: string, comments?: string): Promise<AcquisitionResult> {
|
||||||
|
console.log(`🚀 Starting Acquisition Sequence for: ${url}`);
|
||||||
|
|
||||||
|
// 1. Crawl
|
||||||
|
const crawlData = await this.performCrawl(url);
|
||||||
|
|
||||||
|
// 2. Distill
|
||||||
|
const distilledContext = await this.distillCrawlContext(crawlData);
|
||||||
|
|
||||||
|
// 3. AI Estimation (using parts of the original ai-estimate logic)
|
||||||
|
// For brevity in this initial port, I'll implement a combined prompt strategy
|
||||||
|
// or keep the multi-pass if needed.
|
||||||
|
|
||||||
|
const result = await this.getAiEstimation(briefing, distilledContext, comments || null);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performCrawl(url: string): Promise<string> {
|
||||||
|
const pages: any[] = [];
|
||||||
|
const origin = new URL(url).origin;
|
||||||
|
|
||||||
|
const crawler = new CheerioCrawler({
|
||||||
|
maxRequestsPerCrawl: 15,
|
||||||
|
async requestHandler({ $, request, enqueueLinks }) {
|
||||||
|
const title = $("title").text();
|
||||||
|
const bodyText = $("body").text().replace(/\s+/g, " ").substring(0, 10000);
|
||||||
|
|
||||||
|
pages.push({
|
||||||
|
url: request.url,
|
||||||
|
content: `Title: ${title}\nText: ${bodyText}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enqueueLinks({
|
||||||
|
limit: 10,
|
||||||
|
transformRequestFunction: (req) => {
|
||||||
|
try {
|
||||||
|
const reqUrl = new URL(req.url);
|
||||||
|
if (reqUrl.origin !== origin) return false;
|
||||||
|
if (reqUrl.pathname.match(/\.(pdf|zip|jpg|png|svg|webp)$/i)) return false;
|
||||||
|
return req;
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignored - malformed URL in enqueueLinks
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await crawler.run([url]);
|
||||||
|
return pages.map((p) => `--- PAGE: ${p.url} ---\n${p.content}`).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async distillCrawlContext(rawCrawl: string): Promise<string> {
|
||||||
|
const systemPrompt = `
|
||||||
|
You are a context distiller. Extract the "Company DNA" in 5-8 bullet points (GERMAN).
|
||||||
|
Focus on: Services, USPs, Target Audience, Tone.
|
||||||
|
`;
|
||||||
|
const resp = await axios.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
{
|
||||||
|
model: "google/gemini-3-flash-preview",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: `RAW_CRAWL:\n${rawCrawl.substring(0, 20000)}` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${this.openRouterKey}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return resp.data.choices[0].message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAiEstimation(briefing: string, context: string, comments: string | null): Promise<AcquisitionResult> {
|
||||||
|
// Porting a simplified version of Pass 1 and Pass 3 together for the "Audit"
|
||||||
|
const systemPrompt = `
|
||||||
|
You are a Digital Architect. Analyze the briefing and crawl context.
|
||||||
|
Generate a JSON state for a project estimation.
|
||||||
|
Language: GERMAN.
|
||||||
|
Format: ROOT LEVEL JSON matching FormState interface.
|
||||||
|
|
||||||
|
### PRICING RULES:
|
||||||
|
- Base: 5440 €
|
||||||
|
- Page: 600 €
|
||||||
|
- Feature: 1500 €
|
||||||
|
- Function/API: 800 €
|
||||||
|
|
||||||
|
Return ONLY the JSON.
|
||||||
|
`;
|
||||||
|
const userPrompt = `BRIEFING: ${briefing}\n\nCONTEXT: ${context}\n\nCOMMENTS: ${comments}`;
|
||||||
|
|
||||||
|
const resp = await axios.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
{
|
||||||
|
model: "google/gemini-3-flash-preview",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: userPrompt },
|
||||||
|
],
|
||||||
|
response_format: { type: "json_object" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${this.openRouterKey}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let state: FormState;
|
||||||
|
try {
|
||||||
|
state = JSON.parse(resp.data.choices[0].message.content);
|
||||||
|
} catch (_error) {
|
||||||
|
console.error("Failed to parse AI estimation JSON, returning initial state.");
|
||||||
|
state = initialState;
|
||||||
|
}
|
||||||
|
// Ensure it matches FormState defaults
|
||||||
|
const finalState = { ...initialState, ...state };
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: finalState,
|
||||||
|
usage: {
|
||||||
|
prompt: resp.data.usage?.prompt_tokens || 0,
|
||||||
|
completion: resp.data.usage?.completion_tokens || 0,
|
||||||
|
cost: resp.data.usage?.cost || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/pdf-library/src/services/PdfEngine.ts
Normal file
24
packages/pdf-library/src/services/PdfEngine.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { renderToFile } from "@react-pdf/renderer";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { EstimationPDF } from "../components/EstimationPDF.js";
|
||||||
|
import { PRICING } from "../logic/pricing/constants.js";
|
||||||
|
import { calculateTotals } from "../logic/pricing/calculator.js";
|
||||||
|
|
||||||
|
export class PdfEngine {
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
async generateEstimatePdf(state: any, outputPath: string): Promise<string> {
|
||||||
|
const totals = calculateTotals(state, PRICING);
|
||||||
|
|
||||||
|
await renderToFile(
|
||||||
|
createElement(EstimationPDF as any, {
|
||||||
|
state,
|
||||||
|
totalPrice: totals.totalPrice,
|
||||||
|
pricing: PRICING,
|
||||||
|
} as any) as any,
|
||||||
|
outputPath
|
||||||
|
);
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
packages/pdf-library/src/utils/cache/FileCacheAdapter.ts
vendored
Normal file
78
packages/pdf-library/src/utils/cache/FileCacheAdapter.ts
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
|
||||||
|
export class FileCacheAdapter {
|
||||||
|
private cacheDir: string;
|
||||||
|
private prefix: string;
|
||||||
|
private defaultTTL: number;
|
||||||
|
|
||||||
|
constructor(config?: { cacheDir?: string; prefix?: string; defaultTTL?: number }) {
|
||||||
|
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), '.cache');
|
||||||
|
this.prefix = config?.prefix || '';
|
||||||
|
this.defaultTTL = config?.defaultTTL || 3600;
|
||||||
|
|
||||||
|
if (!existsSync(this.cacheDir)) {
|
||||||
|
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitize(key: string): string {
|
||||||
|
const clean = key.replace(/[^a-z0-9]/gi, '_');
|
||||||
|
if (clean.length > 64) {
|
||||||
|
return crypto.createHash('md5').update(key).digest('hex');
|
||||||
|
}
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFilePath(key: string): string {
|
||||||
|
const safeKey = this.sanitize(`${this.prefix}${key}`).toLowerCase();
|
||||||
|
return path.join(this.cacheDir, `${safeKey}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
const filePath = this.getFilePath(key);
|
||||||
|
try {
|
||||||
|
if (!existsSync(filePath)) return null;
|
||||||
|
const content = await fs.readFile(filePath, 'utf8');
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
|
||||||
|
if (data.expiry && Date.now() > data.expiry) {
|
||||||
|
await this.del(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.value;
|
||||||
|
} catch (_error) {
|
||||||
|
return null; // Keeping original return type Promise<T | null>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||||
|
const filePath = this.getFilePath(key);
|
||||||
|
const effectiveTTL = ttl !== undefined ? ttl : this.defaultTTL;
|
||||||
|
const data = {
|
||||||
|
value,
|
||||||
|
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<void> {
|
||||||
|
const filePath = this.getFilePath(key);
|
||||||
|
try {
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignored - best effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/pdf-library/tsconfig.json
Normal file
24
packages/pdf-library/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "dist",
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build.mjs"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,30 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "people-manager",
|
"name": "people-manager",
|
||||||
"description": "Custom High-Fidelity People Management for Directus",
|
"id": "people-manager",
|
||||||
"icon": "person",
|
"description": "Custom High-Fidelity People Management for Directus",
|
||||||
"version": "1.0.0",
|
"icon": "person",
|
||||||
|
"version": "1.8.2",
|
||||||
|
"type": "module",
|
||||||
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-extension-module"
|
||||||
|
],
|
||||||
|
"directus:extension": {
|
||||||
|
"id": "people-manager",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"keywords": [
|
"path": "index.js",
|
||||||
"directus",
|
"source": "src/index.ts",
|
||||||
"directus-extension",
|
"host": "*",
|
||||||
"directus-extension-module"
|
"name": "People Manager"
|
||||||
],
|
},
|
||||||
"files": [
|
"scripts": {
|
||||||
"dist"
|
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
|
||||||
],
|
"dev": "directus-extension build -w"
|
||||||
"directus:extension": {
|
},
|
||||||
"type": "module",
|
"devDependencies": {
|
||||||
"path": "index.js",
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
"source": "src/index.ts",
|
"vue": "^3.4.0"
|
||||||
"host": "*",
|
}
|
||||||
"name": "People Manager"
|
}
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
|
||||||
"dev": "directus-extension build -w"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"vue": "^3.4.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,356 @@
|
|||||||
<template>
|
<template>
|
||||||
<private-view title="People Manager">
|
<private-view title="People Manager">
|
||||||
<div class="people-manager">
|
<template #navigation>
|
||||||
<h1>People Manager</h1>
|
<v-list nav>
|
||||||
<p>Modern Industrial People Management Interface</p>
|
<v-list-item @click="openCreateDrawer" clickable>
|
||||||
|
<v-list-item-icon>
|
||||||
|
<v-icon name="add" color="var(--theme--primary)" />
|
||||||
|
</v-list-item-icon>
|
||||||
|
<v-list-item-content>
|
||||||
|
<v-text-overflow text="Neue Person anlegen" />
|
||||||
|
</v-list-item-content>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-for="person in people"
|
||||||
|
:key="person.id"
|
||||||
|
:active="selectedPerson?.id === person.id"
|
||||||
|
class="person-item"
|
||||||
|
clickable
|
||||||
|
@click="selectPerson(person)"
|
||||||
|
>
|
||||||
|
<v-list-item-icon>
|
||||||
|
<v-icon name="person" />
|
||||||
|
</v-list-item-icon>
|
||||||
|
<v-list-item-content>
|
||||||
|
<v-text-overflow :text="`${person.first_name} ${person.last_name}`" />
|
||||||
|
</v-list-item-content>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<v-notice v-if="feedback" :type="feedback.type" @close="feedback = null" dismissible>
|
||||||
|
{{ feedback.message }}
|
||||||
|
</v-notice>
|
||||||
|
|
||||||
|
<div v-if="!selectedPerson" class="empty-state">
|
||||||
|
<v-info title="Person auswählen" icon="person" center>
|
||||||
|
Wähle eine Person in der Navigation aus oder
|
||||||
|
<v-button x-small @click="openCreateDrawer">erstelle eine neue Person</v-button>.
|
||||||
|
</v-info>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1 class="title">{{ selectedPerson.first_name }} {{ selectedPerson.last_name }}</h1>
|
||||||
|
<p class="subtitle">{{ selectedPerson.email || 'Keine E-Mail angegeben' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<v-button secondary rounded icon v-tooltip="'Person bearbeiten'" @click="openEditDrawer">
|
||||||
|
<v-icon name="edit" />
|
||||||
|
</v-button>
|
||||||
|
<v-button danger rounded icon v-tooltip="'Person löschen'" @click="deletePerson">
|
||||||
|
<v-icon name="delete" />
|
||||||
|
</v-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<div class="details-grid">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">Organisation / Firma</span>
|
||||||
|
<p class="value">{{ getCompanyName(selectedPerson) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">Telefon</span>
|
||||||
|
<p class="value">{{ selectedPerson.phone || '---' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Drawer -->
|
||||||
|
<v-drawer
|
||||||
|
v-model="drawerActive"
|
||||||
|
:title="isEditing ? 'Person bearbeiten' : 'Neue Person anlegen'"
|
||||||
|
icon="person"
|
||||||
|
@cancel="drawerActive = false"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<div class="drawer-content">
|
||||||
|
<div class="form-section">
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Vorname</span>
|
||||||
|
<v-input v-model="form.first_name" placeholder="Vorname" autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Nachname</span>
|
||||||
|
<v-input v-model="form.last_name" placeholder="Nachname" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">E-Mail</span>
|
||||||
|
<v-input v-model="form.email" placeholder="email@beispiel.de" type="email" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Zentrale Firma</span>
|
||||||
|
<v-select
|
||||||
|
v-model="form.company"
|
||||||
|
:items="companyOptions"
|
||||||
|
placeholder="Bestehende Firma auswählen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Firma (Legacy / Neu)</span>
|
||||||
|
<v-input v-model="form.company_name" placeholder="z.B. Mintel" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Telefon</span>
|
||||||
|
<v-input v-model="form.phone" placeholder="+49 ..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-actions">
|
||||||
|
<v-button primary block :loading="saving" @click="savePerson">
|
||||||
|
Person speichern
|
||||||
|
</v-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-drawer>
|
||||||
</private-view>
|
</private-view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// Logic will be added here
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const people = ref([]);
|
||||||
|
const companies = ref([]);
|
||||||
|
const selectedPerson = ref(null);
|
||||||
|
const feedback = ref(null);
|
||||||
|
const saving = ref(false);
|
||||||
|
const drawerActive = ref(false);
|
||||||
|
const isEditing = ref(false);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
id: null,
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
email: '',
|
||||||
|
company: null,
|
||||||
|
company_name: '',
|
||||||
|
phone: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
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 || person.company_name);
|
||||||
|
}
|
||||||
|
return person.company_name || '---';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const [peopleResp, companiesResp] = await Promise.all([
|
||||||
|
api.get('/items/people', {
|
||||||
|
params: {
|
||||||
|
sort: 'last_name',
|
||||||
|
fields: '*.*'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
api.get('/items/companies', {
|
||||||
|
params: { sort: 'name' }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
people.value = peopleResp.data.data;
|
||||||
|
companies.value = companiesResp.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPerson(person) {
|
||||||
|
selectedPerson.value = person;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDrawer() {
|
||||||
|
isEditing.value = false;
|
||||||
|
form.value = {
|
||||||
|
id: null,
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
email: '',
|
||||||
|
company: null,
|
||||||
|
company_name: '',
|
||||||
|
phone: ''
|
||||||
|
};
|
||||||
|
drawerActive.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDrawer() {
|
||||||
|
isEditing.value = true;
|
||||||
|
const person = selectedPerson.value;
|
||||||
|
let companyId = null;
|
||||||
|
let companyName = person.company_name || '';
|
||||||
|
|
||||||
|
if (person.company) {
|
||||||
|
if (typeof person.company === 'object') {
|
||||||
|
companyId = person.company.id;
|
||||||
|
} else if (person.company.length === 36) { // Assume UUID
|
||||||
|
companyId = person.company;
|
||||||
|
} else {
|
||||||
|
companyName = person.company;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.value = {
|
||||||
|
...person,
|
||||||
|
company: companyId,
|
||||||
|
company_name: companyName
|
||||||
|
};
|
||||||
|
drawerActive.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePerson() {
|
||||||
|
if (!form.value.first_name || !form.value.last_name) {
|
||||||
|
feedback.value = { type: 'danger', message: 'Vor- und Nachname sind erforderlich.' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (isEditing.value) {
|
||||||
|
await api.patch(`/items/people/${form.value.id}`, form.value);
|
||||||
|
feedback.value = { type: 'success', message: 'Person aktualisiert!' };
|
||||||
|
} else {
|
||||||
|
await api.post('/items/people', form.value);
|
||||||
|
feedback.value = { type: 'success', message: 'Person angelegt!' };
|
||||||
|
}
|
||||||
|
drawerActive.value = false;
|
||||||
|
await fetchData();
|
||||||
|
if (isEditing.value) {
|
||||||
|
selectedPerson.value = people.value.find(p => p.id === form.value.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
feedback.value = { type: 'danger', message: error.message };
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePerson() {
|
||||||
|
if (!confirm('Soll diese Person wirklich gelöscht werden?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/items/people/${selectedPerson.value.id}`);
|
||||||
|
feedback.value = { type: 'success', message: 'Person gelöscht.' };
|
||||||
|
selectedPerson.value = null;
|
||||||
|
await fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
feedback.value = { type: 'danger', message: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchData);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.people-manager {
|
.content-wrapper {
|
||||||
padding: 20px;
|
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 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 32px;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--theme--foreground-subdued);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-content {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-actions {
|
||||||
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/tsconfig",
|
"name": "@mintel/tsconfig",
|
||||||
"version": "1.7.3",
|
"version": "1.8.2",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user