Compare commits
9 Commits
feature/aq
...
v1.7.6
| Author | SHA1 | Date | |
|---|---|---|---|
| bfdbaba0d0 | |||
| 4ea9cbc551 | |||
| d8c1a38c0d | |||
| b65b9a7fb2 | |||
| 858c7bbc39 | |||
| 149123ef90 | |||
| 6bc49d1c52 | |||
| 52ffe49019 | |||
| 73fa292528 |
@@ -1,5 +1,5 @@
|
|||||||
# Project
|
# Project
|
||||||
IMAGE_TAG=v1.7.0
|
IMAGE_TAG=v1.7.3
|
||||||
PROJECT_NAME=sample-website
|
PROJECT_NAME=sample-website
|
||||||
PROJECT_COLOR=#82ed20
|
PROJECT_COLOR=#82ed20
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,35 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
qa:
|
prioritize:
|
||||||
name: 🧪 Quality Assurance
|
name: ⚡ Prioritize Release
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- name: 🛑 Cancel Redundant Runs
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
RUN_ID: ${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
echo "🚀 Release detected. Cancelling non-tag runs..."
|
||||||
|
# Get all runs for this repo
|
||||||
|
RUNS=$(curl -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs")
|
||||||
|
|
||||||
|
# Iterate and cancel in_progress/queued non-tag runs
|
||||||
|
echo "$RUNS" | jq -c '.workflow_runs[] | select(.status == "in_progress" or .status == "queued") | select(.id | tostring != "'$RUN_ID'") | select(.event != "push" or .ref | contains("refs/tags/v") | not)' | while read run; do
|
||||||
|
ID=$(echo "$run" | jq -r '.id')
|
||||||
|
DESC=$(echo "$run" | jq -r '.display_title')
|
||||||
|
echo "🛑 Cancelling redundant run $ID ($DESC)..."
|
||||||
|
curl -X POST -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs/$ID/cancel"
|
||||||
|
done
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: 🧹 Lint
|
||||||
|
needs: prioritize
|
||||||
|
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
@@ -22,36 +49,78 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Environment
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
run_install: |
|
||||||
|
- recursive: true
|
||||||
|
args: [--frozen-lockfile, --prefer-offline]
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: 🏷️ Sync Versions (if Tagged)
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
run: pnpm sync-versions
|
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: 🧪 Test
|
||||||
|
needs: prioritize
|
||||||
|
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Environment
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: |
|
||||||
|
- recursive: true
|
||||||
|
args: [--frozen-lockfile, --prefer-offline]
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node_version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: 🏗️ Build
|
||||||
|
needs: prioritize
|
||||||
|
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Environment
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: |
|
||||||
|
- recursive: true
|
||||||
|
args: [--frozen-lockfile, --prefer-offline]
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node_version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: 🚀 Release
|
name: 🚀 Release
|
||||||
needs: qa
|
needs: [lint, test, build]
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -65,18 +134,22 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Set up Environment
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
|
run_install: |
|
||||||
|
- recursive: true
|
||||||
|
args: [--frozen-lockfile, --prefer-offline]
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: 🏷️ Sync Versions (if Tagged)
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm sync-versions
|
||||||
|
|
||||||
- name: 🏷️ Release Packages (Tag-Driven)
|
- name: 🏷️ Release Packages (Tag-Driven)
|
||||||
run: |
|
run: |
|
||||||
@@ -85,7 +158,7 @@ jobs:
|
|||||||
|
|
||||||
build-images:
|
build-images:
|
||||||
name: 🐳 Build ${{ matrix.name }}
|
name: 🐳 Build ${{ matrix.name }}
|
||||||
needs: qa
|
needs: [lint, test, build]
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ do
|
|||||||
echo "🏷️ Tag detected: $TAG, syncing versions..."
|
echo "🏷️ Tag detected: $TAG, syncing versions..."
|
||||||
pnpm sync-versions "$TAG"
|
pnpm sync-versions "$TAG"
|
||||||
|
|
||||||
# Stage the changed files
|
# Stage the changed files (excluding ignored files like .env)
|
||||||
git add package.json packages/*/package.json apps/*/package.json .env .env.example
|
git add package.json packages/*/package.json apps/*/package.json .env.example
|
||||||
|
|
||||||
echo "⚠️ package.json and .env files updated to match tag $TAG."
|
echo "⚠️ package.json and .env files updated to match tag $TAG."
|
||||||
echo "⚠️ Note: You might need to push again if these changes were not already in your commit/tag."
|
echo "⚠️ Note: You might need to push again if these changes were not already in your commit/tag."
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Start from the pre-built Nextjs Base image
|
# Start from the pre-built Nextjs Base image
|
||||||
ARG IMAGE_TAG=latest
|
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:${IMAGE_TAG} AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
|
|||||||
RUN pnpm --filter sample-website build
|
RUN pnpm --filter sample-website build
|
||||||
|
|
||||||
# Production runner image
|
# Production runner image
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:${IMAGE_TAG} AS runner
|
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public
|
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sample-website",
|
"name": "sample-website",
|
||||||
"version": "1.7.0",
|
"version": "1.7.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -52,7 +52,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.0",
|
"version": "1.7.3",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@mintel/acquisition",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"module": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
||||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
|
||||||
"lint": "eslint src",
|
|
||||||
"test": "vitest run",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"axios": "^1.7.9",
|
|
||||||
"crawlee": "^3.12.2",
|
|
||||||
"cheerio": "^1.0.0",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"@react-pdf/renderer": "^4.3.0",
|
|
||||||
"framer-motion": "^12.4.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@mintel/tsconfig": "workspace:*",
|
|
||||||
"@mintel/eslint-config": "workspace:*",
|
|
||||||
"tsup": "^8.3.5",
|
|
||||||
"typescript": "^5.0.0",
|
|
||||||
"vitest": "^3.0.4",
|
|
||||||
"@types/node": "^20.17.16"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"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;
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
"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
|
|
||||||
};
|
|
||||||
|
|
||||||
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 Footer = ({
|
|
||||||
logo,
|
|
||||||
companyData,
|
|
||||||
showDetails = true,
|
|
||||||
showPageNumber = true,
|
|
||||||
}: {
|
|
||||||
logo?: string;
|
|
||||||
companyData: 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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
'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;
|
|
||||||
showPageNumber?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SimpleLayout = ({
|
|
||||||
children,
|
|
||||||
pageNumber,
|
|
||||||
icon,
|
|
||||||
footerLogo,
|
|
||||||
companyData,
|
|
||||||
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}
|
|
||||||
showDetails={false}
|
|
||||||
showPageNumber={showPageNumber}
|
|
||||||
/>
|
|
||||||
</PDFPage>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"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 },
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
"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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import * as React from "react";
|
|
||||||
import { View as PDFView, Text as PDFText, StyleSheet } from "@react-pdf/renderer";
|
|
||||||
import { DocumentTitle, COLORS, FONT_SIZES, IndustrialListItem } from "../SharedUI.js";
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
section: { marginBottom: 24 },
|
|
||||||
categoryBox: {
|
|
||||||
marginBottom: 20,
|
|
||||||
padding: 12,
|
|
||||||
backgroundColor: COLORS.GRID,
|
|
||||||
borderLeftWidth: 2,
|
|
||||||
borderLeftColor: COLORS.DIVIDER,
|
|
||||||
},
|
|
||||||
categoryTitle: {
|
|
||||||
fontSize: FONT_SIZES.TINY,
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: COLORS.TEXT_LIGHT,
|
|
||||||
textTransform: "uppercase",
|
|
||||||
marginBottom: 10,
|
|
||||||
letterSpacing: 1,
|
|
||||||
},
|
|
||||||
pageTitle: {
|
|
||||||
fontSize: FONT_SIZES.LABEL,
|
|
||||||
fontWeight: "bold",
|
|
||||||
color: COLORS.CHARCOAL,
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
pageDesc: {
|
|
||||||
fontSize: FONT_SIZES.TINY,
|
|
||||||
color: COLORS.TEXT_DIM,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SitemapModule = ({ state }: any) => (
|
|
||||||
<>
|
|
||||||
<DocumentTitle title="Informations-Architektur" isHero={true} />
|
|
||||||
<PDFView style={styles.section}>
|
|
||||||
{state.sitemap?.map((cat: any, i: number) => (
|
|
||||||
<PDFView key={i} style={styles.categoryBox}>
|
|
||||||
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
|
|
||||||
{cat.pages?.map((p: any, j: number) => (
|
|
||||||
<IndustrialListItem key={j}>
|
|
||||||
<PDFView style={{ marginBottom: 8 }}>
|
|
||||||
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
|
|
||||||
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
|
|
||||||
</PDFView>
|
|
||||||
</IndustrialListItem>
|
|
||||||
))}
|
|
||||||
</PDFView>
|
|
||||||
))}
|
|
||||||
</PDFView>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export * from "./logic/pricing/types.js";
|
|
||||||
export * from "./logic/pricing/constants.js";
|
|
||||||
export * from "./logic/pricing/calculator.js";
|
|
||||||
export * from "./services/AcquisitionService.js";
|
|
||||||
export * from "./services/PdfEngine.js";
|
|
||||||
export * from "./components/EstimationPDF.js";
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
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",
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { CheerioCrawler } from "crawlee";
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { calculateTotals, calculatePositions } from "../src/logic/pricing/calculator.js";
|
|
||||||
import { PRICING, initialState } from "../src/logic/pricing/constants.js";
|
|
||||||
import { FormState } from "../src/logic/pricing/types.js";
|
|
||||||
|
|
||||||
describe("Pricing Logic", () => {
|
|
||||||
it("should calculate base website price correctly", () => {
|
|
||||||
const state: FormState = {
|
|
||||||
...initialState,
|
|
||||||
projectType: "website",
|
|
||||||
selectedPages: [] // Clear for base test
|
|
||||||
};
|
|
||||||
const totals = calculateTotals(state, PRICING);
|
|
||||||
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add page costs correctly", () => {
|
|
||||||
const state: FormState = {
|
|
||||||
...initialState,
|
|
||||||
projectType: "website",
|
|
||||||
selectedPages: [], // Clear for clean test
|
|
||||||
otherPagesCount: 5
|
|
||||||
};
|
|
||||||
const totals = calculateTotals(state, PRICING);
|
|
||||||
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE + (5 * PRICING.PAGE));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should apply multi-language multiplier", () => {
|
|
||||||
const state: FormState = {
|
|
||||||
...initialState,
|
|
||||||
projectType: "website",
|
|
||||||
selectedPages: [], // Clear for clean test
|
|
||||||
languagesList: ["Deutsch", "Englisch"]
|
|
||||||
};
|
|
||||||
const totals = calculateTotals(state, PRICING);
|
|
||||||
expect(totals.totalPrice).toBe(Math.round(PRICING.BASE_WEBSITE * 1.2));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should generate correct positions for a website", () => {
|
|
||||||
const state: FormState = {
|
|
||||||
...initialState,
|
|
||||||
projectType: "website",
|
|
||||||
selectedPages: ["Home"],
|
|
||||||
otherPagesCount: 2
|
|
||||||
};
|
|
||||||
const positions = calculatePositions(state, PRICING);
|
|
||||||
|
|
||||||
// Find "Fundament" position (Das technische Fundament)
|
|
||||||
const basePos = positions.find(p => p.title.includes("Fundament"));
|
|
||||||
expect(basePos).toBeDefined();
|
|
||||||
expect(basePos?.price).toBe(PRICING.BASE_WEBSITE);
|
|
||||||
|
|
||||||
// Find "Individuelle Seiten" position
|
|
||||||
const pagesPos = positions.find(p => p.title.includes("Seiten"));
|
|
||||||
expect(pagesPos).toBeDefined();
|
|
||||||
expect(pagesPos?.qty).toBe(3); // 1 selected + 2 other
|
|
||||||
expect(pagesPos?.price).toBe(3 * PRICING.PAGE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@mintel/tsconfig/base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"jsx": "react-jsx"
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/**/*"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cli",
|
"name": "@mintel/cli",
|
||||||
"version": "1.7.0",
|
"version": "1.7.3",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
# Build Stage
|
|
||||||
FROM node:20-slim AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Core environment for pnpm
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
# Copy root configurations
|
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
|
||||||
|
|
||||||
# Copy all packages for extensions build
|
|
||||||
COPY packages ./packages
|
|
||||||
|
|
||||||
# Install dependencies (only what's needed for extensions)
|
|
||||||
RUN pnpm install --no-frozen-lockfile \
|
|
||||||
--filter "@mintel/directus-extension-*" \
|
|
||||||
--filter "acquisition" \
|
|
||||||
--filter "acquisition-manager" \
|
|
||||||
--filter "customer-manager" \
|
|
||||||
--filter "feedback-commander" \
|
|
||||||
--filter "people-manager" \
|
|
||||||
--filter "./packages/acquisition" \
|
|
||||||
--filter "./packages/mail"
|
|
||||||
|
|
||||||
|
|
||||||
# Runtime Stage
|
|
||||||
FROM directus/directus:11
|
|
||||||
|
|
||||||
WORKDIR /directus
|
|
||||||
|
|
||||||
# Copy built extensions
|
|
||||||
COPY --from=builder /app/packages/cms-infra/extensions ./extensions
|
|
||||||
|
|
||||||
# Environment defaults (can be overridden)
|
|
||||||
ENV KEY="infra-cms-key"
|
|
||||||
ENV SECRET="infra-cms-secret"
|
|
||||||
ENV DB_CLIENT="sqlite3"
|
|
||||||
ENV DB_FILENAME="/directus/database/data.db"
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8055
|
|
||||||
Binary file not shown.
@@ -1,9 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
infra-cms:
|
infra-cms:
|
||||||
build:
|
image: directus/directus:11
|
||||||
context: ../../
|
|
||||||
dockerfile: packages/cms-infra/Dockerfile
|
|
||||||
image: mintel/cms-infra:latest
|
|
||||||
ports:
|
ports:
|
||||||
- "8059:8055"
|
- "8059:8055"
|
||||||
networks:
|
networks:
|
||||||
@@ -24,7 +21,6 @@ services:
|
|||||||
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
|
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
|
||||||
EMAIL_SMTP_SECURE: "false"
|
EMAIL_SMTP_SECURE: "false"
|
||||||
EMAIL_FROM: "postmaster@mg.mintel.me"
|
EMAIL_FROM: "postmaster@mg.mintel.me"
|
||||||
LOG_LEVEL: "trace"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./database:/directus/database
|
- ./database:/directus/database
|
||||||
- ./uploads:/directus/uploads
|
- ./uploads:/directus/uploads
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "acquisition-manager",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "module",
|
|
||||||
"path": "dist/index.js",
|
|
||||||
"source": "src/index.ts",
|
|
||||||
"host": "^11.0.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "directus-extension build",
|
|
||||||
"dev": "directus-extension build -w"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"vue": "^3.4.34",
|
|
||||||
"typescript": "^5.6.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { defineModule } from "@directus/extensions-sdk";
|
|
||||||
import ModuleComponent from "./module.vue";
|
|
||||||
|
|
||||||
export default defineModule({
|
|
||||||
id: "acquisition-manager",
|
|
||||||
name: "Acquisition",
|
|
||||||
icon: "auto_awesome",
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: "",
|
|
||||||
component: ModuleComponent,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ":id",
|
|
||||||
component: ModuleComponent,
|
|
||||||
props: true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
<template>
|
|
||||||
<private-view title="Acquisition Manager">
|
|
||||||
<template #navigation>
|
|
||||||
<v-list nav>
|
|
||||||
<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="lead.company_name" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #title-outer:after>
|
|
||||||
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
|
|
||||||
{{ notice.message }}
|
|
||||||
</v-notice>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="content-wrapper">
|
|
||||||
<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">{{ selectedLead.company_name }}</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>
|
|
||||||
|
|
||||||
<!-- 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">Firma</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>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
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: '',
|
|
||||||
website_url: '',
|
|
||||||
contact_name: '',
|
|
||||||
contact_email: '',
|
|
||||||
contact_person: null,
|
|
||||||
briefing: '',
|
|
||||||
status: 'new'
|
|
||||||
});
|
|
||||||
|
|
||||||
const people = ref<any[]>([]);
|
|
||||||
|
|
||||||
const peopleOptions = computed(() =>
|
|
||||||
people.value.map(p => ({
|
|
||||||
text: `${p.first_name} ${p.last_name}`,
|
|
||||||
value: p.id
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
function getPersonName(id: string) {
|
|
||||||
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(fetchLeads);
|
|
||||||
|
|
||||||
async function fetchLeads() {
|
|
||||||
const [leadsResp, peopleResp] = await Promise.all([
|
|
||||||
api.get('/items/leads', { params: { sort: '-date_created' } }),
|
|
||||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
|
||||||
]);
|
|
||||||
leads.value = leadsResp.data.data;
|
|
||||||
people.value = peopleResp.data.data;
|
|
||||||
if (!selectedLeadId.value && leads.value.length > 0) {
|
|
||||||
selectedLeadId.value = leads.value[0].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectLead(id: string) {
|
|
||||||
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) 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: '',
|
|
||||||
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>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; overflow-y: auto; }
|
|
||||||
.lead-item { cursor: pointer; }
|
|
||||||
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
|
|
||||||
.header-right { display: flex; gap: 12px; }
|
|
||||||
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; color: var(--theme--foreground); }
|
|
||||||
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
|
||||||
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
|
|
||||||
.url-link: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>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { build } from 'esbuild';
|
|
||||||
import { resolve, dirname } from 'path';
|
|
||||||
import { mkdirSync } from 'fs';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
const entryPoint = resolve(__dirname, 'src/index.ts');
|
|
||||||
const outfile = resolve(__dirname, 'dist/index.js');
|
|
||||||
|
|
||||||
try {
|
|
||||||
mkdirSync(dirname(outfile), { recursive: true });
|
|
||||||
} catch (e) { }
|
|
||||||
|
|
||||||
console.log(`Building from ${entryPoint} to ${outfile}...`);
|
|
||||||
|
|
||||||
build({
|
|
||||||
entryPoints: [entryPoint],
|
|
||||||
bundle: true,
|
|
||||||
platform: 'node',
|
|
||||||
target: 'node18',
|
|
||||||
outfile: outfile,
|
|
||||||
format: 'esm',
|
|
||||||
// Bundle everything, including Directus SDK, to avoid resolution issues in Docker
|
|
||||||
external: [],
|
|
||||||
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',
|
|
||||||
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) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}).then(() => {
|
|
||||||
console.log("Build succeeded!");
|
|
||||||
}).catch((e) => {
|
|
||||||
console.error("Build failed:", e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "acquisition",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "endpoint",
|
|
||||||
"path": "dist/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",
|
|
||||||
"@mintel/acquisition": "workspace:*",
|
|
||||||
"@mintel/mail": "workspace:*",
|
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"typescript": "^5.6.3"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"jquery": "^3.7.1",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-dom": "^19.2.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
import "./shim";
|
|
||||||
import { defineEndpoint } from "@directus/extensions-sdk";
|
|
||||||
import { AcquisitionService, PdfEngine } from "@mintel/acquisition";
|
|
||||||
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
|
|
||||||
import { createElement } from "react";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as fs from "fs";
|
|
||||||
|
|
||||||
export default defineEndpoint((router, { services, env }) => {
|
|
||||||
const { ItemsService, MailService } = services;
|
|
||||||
|
|
||||||
router.get("/ping", (req, res) => res.send("pong"));
|
|
||||||
|
|
||||||
router.post("/audit/:id", async (req: any, res: any) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lead = await leadsService.readOne(id);
|
|
||||||
if (!lead) return res.status(404).send({ error: "Lead not found" });
|
|
||||||
|
|
||||||
await leadsService.updateOne(id, { status: "auditing" });
|
|
||||||
|
|
||||||
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
|
|
||||||
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments);
|
|
||||||
|
|
||||||
await leadsService.updateOne(id, {
|
|
||||||
status: "audit_ready",
|
|
||||||
ai_state: result.state,
|
|
||||||
audit_context: JSON.stringify(result.usage),
|
|
||||||
});
|
|
||||||
|
|
||||||
res.send({ success: true, result });
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Audit failed:", error);
|
|
||||||
await leadsService.updateOne(id, { status: "new", comments: `Audit failed: ${error.message}` });
|
|
||||||
res.status(500).send({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/audit-email/:id", async (req: any, res: any) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
|
||||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
|
||||||
const mailService = new MailService({ 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 Audit not ready" });
|
|
||||||
|
|
||||||
let recipientEmail = lead.contact_email;
|
|
||||||
let companyName = lead.company_name;
|
|
||||||
|
|
||||||
if (lead.contact_person) {
|
|
||||||
const person = await peopleService.readOne(lead.contact_person);
|
|
||||||
if (person && person.email) {
|
|
||||||
recipientEmail = person.email;
|
|
||||||
companyName = person.company || lead.company_name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lead = await leadsService.readOne(id);
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (lead.contact_person) {
|
|
||||||
const person = await peopleService.readOne(lead.contact_person);
|
|
||||||
if (person && person.email) {
|
|
||||||
recipientEmail = person.email;
|
|
||||||
companyName = person.company || lead.company_name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,22 +0,0 @@
|
|||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { dirname } from 'path';
|
|
||||||
import { createRequire } from 'module';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = import.meta?.url;
|
|
||||||
// Hardcode fallback path for Directus Docker environment
|
|
||||||
const fallbackPath = '/directus/extensions/acquisition/dist/index.js';
|
|
||||||
const filename = url ? fileURLToPath(url) : fallbackPath;
|
|
||||||
const dir = dirname(filename);
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
globalThis.__filename = filename;
|
|
||||||
// @ts-ignore
|
|
||||||
globalThis.__dirname = dir;
|
|
||||||
// @ts-ignore
|
|
||||||
globalThis.require = createRequire(url || `file://${fallbackPath}`);
|
|
||||||
|
|
||||||
console.log(`[Shim] Loaded. __dirname: ${dir}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[Shim] Failed to shim __dirname/require", e);
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,12 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "customer-manager",
|
"name": "customer-manager",
|
||||||
|
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||||
|
"icon": "supervisor_account",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-extension-module"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
"directus:extension": {
|
"directus:extension": {
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"path": "dist/index.js",
|
"path": "index.js",
|
||||||
"source": "src/index.ts",
|
"source": "src/index.ts",
|
||||||
"host": "^11.0.0"
|
"host": "*",
|
||||||
|
"name": "Customer Manager"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "directus-extension build",
|
"build": "directus-extension build",
|
||||||
@@ -14,7 +24,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
"vue": "^3.4.34",
|
"vue": "^3.4.0"
|
||||||
"typescript": "^5.6.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { defineModule } from '@directus/extensions-sdk';
|
|
||||||
import ModuleComponent from './module.vue';
|
|
||||||
|
|
||||||
export default defineModule({
|
|
||||||
id: 'customer-manager',
|
|
||||||
name: 'Customer Manager',
|
|
||||||
icon: 'supervisor_account',
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
component: ModuleComponent,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
<template>
|
|
||||||
<private-view title="Customer Manager">
|
|
||||||
<template #navigation>
|
|
||||||
<v-list nav>
|
|
||||||
<v-list-item @click="openCreateCompany" clickable>
|
|
||||||
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
|
|
||||||
<v-list-item-content>
|
|
||||||
<v-text-overflow text="Neue Firma anlegen" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-list-item
|
|
||||||
v-for="company in companies"
|
|
||||||
:key="company.id"
|
|
||||||
:active="selectedCompany?.id === company.id"
|
|
||||||
class="company-item"
|
|
||||||
clickable
|
|
||||||
@click="selectCompany(company)"
|
|
||||||
>
|
|
||||||
<v-list-item-icon><v-icon name="business" /></v-list-item-icon>
|
|
||||||
<v-list-item-content>
|
|
||||||
<v-text-overflow :text="company.name" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #title-outer:after>
|
|
||||||
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
|
|
||||||
{{ notice.message }}
|
|
||||||
</v-notice>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="content-wrapper">
|
|
||||||
<div v-if="!selectedCompany" class="empty-state">
|
|
||||||
<v-info title="Firmen auswählen" icon="business" center>
|
|
||||||
Wähle eine Firma in der Navigation aus oder
|
|
||||||
<v-button x-small @click="openCreateCompany">erstelle eine neue Firma</v-button>.
|
|
||||||
</v-info>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<header class="header">
|
|
||||||
<div class="header-left">
|
|
||||||
<h1 class="title">{{ selectedCompany.name }}</h1>
|
|
||||||
<p class="subtitle">{{ employees.length }} Kunden-Mitarbeiter</p>
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditCompany">
|
|
||||||
<v-icon name="edit" />
|
|
||||||
</v-button>
|
|
||||||
<v-button primary @click="openCreateEmployee">
|
|
||||||
Mitarbeiter hinzufügen
|
|
||||||
</v-button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<v-table
|
|
||||||
:headers="tableHeaders"
|
|
||||||
:items="employees"
|
|
||||||
:loading="loading"
|
|
||||||
class="clickable-table"
|
|
||||||
fixed-header
|
|
||||||
@click:row="onRowClick"
|
|
||||||
>
|
|
||||||
<template #[`item.name`]="{ item }">
|
|
||||||
<div class="user-cell">
|
|
||||||
<v-avatar :name="item.first_name" x-small />
|
|
||||||
<span class="user-name">{{ item.first_name }} {{ item.last_name }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #[`item.last_invited`]="{ item }">
|
|
||||||
<span v-if="item.last_invited" class="status-date">
|
|
||||||
{{ formatDate(item.last_invited) }}
|
|
||||||
</span>
|
|
||||||
<v-chip v-else x-small>Noch nie</v-chip>
|
|
||||||
</template>
|
|
||||||
</v-table>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Drawer: Company Form -->
|
|
||||||
<v-drawer
|
|
||||||
v-model="drawerCompanyActive"
|
|
||||||
:title="isEditingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
|
|
||||||
icon="business"
|
|
||||||
@cancel="drawerCompanyActive = false"
|
|
||||||
>
|
|
||||||
<div v-if="drawerCompanyActive" class="drawer-content">
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Firmenname</span>
|
|
||||||
<v-input v-model="companyForm.name" placeholder="z.B. KLZ Cables" autofocus />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="drawer-actions">
|
|
||||||
<v-button primary block :loading="saving" @click="saveCompany">Speichern</v-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-drawer>
|
|
||||||
|
|
||||||
<!-- Drawer: Employee Form -->
|
|
||||||
<v-drawer
|
|
||||||
v-model="drawerEmployeeActive"
|
|
||||||
:title="isEditingEmployee ? 'Mitarbeiter bearbeiten' : 'Neuen Mitarbeiter anlegen'"
|
|
||||||
icon="person"
|
|
||||||
@cancel="drawerEmployeeActive = false"
|
|
||||||
>
|
|
||||||
<div v-if="drawerEmployeeActive" class="drawer-content">
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Vorname</span>
|
|
||||||
<v-input v-model="employeeForm.first_name" placeholder="Vorname" autofocus />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Nachname</span>
|
|
||||||
<v-input v-model="employeeForm.last_name" placeholder="Nachname" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">E-Mail</span>
|
|
||||||
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<span class="label">Zugehörige Person (Zentral)</span>
|
|
||||||
<v-select
|
|
||||||
v-model="employeeForm.contact_person"
|
|
||||||
:items="peopleOptions"
|
|
||||||
placeholder="Zentrale Person auswählen..."
|
|
||||||
show-deselect
|
|
||||||
/>
|
|
||||||
<p class="field-note">Verknüpft diesen Mitarbeiter mit dem globalen Personen-Verzeichnis.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-divider v-if="isEditingEmployee" />
|
|
||||||
|
|
||||||
<div v-if="isEditingEmployee" class="field">
|
|
||||||
<span class="label">Temporäres Passwort</span>
|
|
||||||
<v-input v-model="employeeForm.temporary_password" readonly class="password-input" />
|
|
||||||
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="drawer-actions">
|
|
||||||
<v-button primary block :loading="saving" @click="saveEmployee">Daten speichern</v-button>
|
|
||||||
|
|
||||||
<template v-if="isEditingEmployee">
|
|
||||||
<v-divider />
|
|
||||||
<v-button
|
|
||||||
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
|
|
||||||
secondary
|
|
||||||
block
|
|
||||||
:loading="invitingId === employeeForm.id"
|
|
||||||
@click="inviteUser(employeeForm)"
|
|
||||||
>
|
|
||||||
<v-icon name="send" left /> Zugangsdaten senden
|
|
||||||
</v-button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-drawer>
|
|
||||||
</private-view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, nextTick, computed } from 'vue';
|
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
const companies = ref<any[]>([]);
|
|
||||||
const selectedCompany = ref<any>(null);
|
|
||||||
const employees = ref<any[]>([]);
|
|
||||||
const people = ref<any[]>([]);
|
|
||||||
const loading = ref(false);
|
|
||||||
const saving = ref(false);
|
|
||||||
const invitingId = ref<string | null>(null);
|
|
||||||
const notice = ref<{ type: string; message: string } | null>(null);
|
|
||||||
|
|
||||||
const peopleOptions = computed(() =>
|
|
||||||
people.value.map(p => ({
|
|
||||||
text: `${p.first_name} ${p.last_name} (${p.company || 'Keine Firma'})`,
|
|
||||||
value: p.id
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Forms State
|
|
||||||
const drawerCompanyActive = ref(false);
|
|
||||||
const isEditingCompany = ref(false);
|
|
||||||
const companyForm = ref({ id: '', name: '' });
|
|
||||||
|
|
||||||
const drawerEmployeeActive = ref(false);
|
|
||||||
const isEditingEmployee = ref(false);
|
|
||||||
const employeeForm = ref({
|
|
||||||
id: '',
|
|
||||||
first_name: '',
|
|
||||||
last_name: '',
|
|
||||||
email: '',
|
|
||||||
contact_person: null as string | null,
|
|
||||||
temporary_password: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const tableHeaders = [
|
|
||||||
{ text: 'Name', value: 'name', sortable: true },
|
|
||||||
{ text: 'E-Mail', value: 'email', sortable: true },
|
|
||||||
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
|
|
||||||
];
|
|
||||||
|
|
||||||
async function fetchCompanies() {
|
|
||||||
const [companyRes, peopleRes] = await Promise.all([
|
|
||||||
api.get('/items/companies', { params: { fields: ['id', 'name'], sort: 'name' } }),
|
|
||||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
|
||||||
]);
|
|
||||||
companies.value = companyRes.data.data;
|
|
||||||
people.value = peopleRes.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectCompany(company: any) {
|
|
||||||
selectedCompany.value = company;
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await api.get('/items/client_users', {
|
|
||||||
params: {
|
|
||||||
filter: { company: { _eq: company.id } },
|
|
||||||
fields: ['*'],
|
|
||||||
sort: 'first_name',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
employees.value = res.data.data;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Company Actions
|
|
||||||
function openCreateCompany() {
|
|
||||||
isEditingCompany.value = false;
|
|
||||||
companyForm.value = { id: '', name: '' };
|
|
||||||
drawerCompanyActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEditCompany() {
|
|
||||||
if (!selectedCompany.value) return;
|
|
||||||
companyForm.value = {
|
|
||||||
id: selectedCompany.value.id,
|
|
||||||
name: selectedCompany.value.name
|
|
||||||
};
|
|
||||||
isEditingCompany.value = true;
|
|
||||||
await nextTick();
|
|
||||||
drawerCompanyActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCompany() {
|
|
||||||
if (!companyForm.value.name) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
if (isEditingCompany.value) {
|
|
||||||
await api.patch(`/items/companies/${companyForm.value.id}`, { name: companyForm.value.name });
|
|
||||||
notice.value = { type: 'success', message: 'Firma aktualisiert!' };
|
|
||||||
} else {
|
|
||||||
await api.post('/items/companies', { name: companyForm.value.name });
|
|
||||||
notice.value = { type: 'success', message: 'Firma angelegt!' };
|
|
||||||
}
|
|
||||||
drawerCompanyActive.value = false;
|
|
||||||
await fetchCompanies();
|
|
||||||
if (selectedCompany.value?.id === companyForm.value.id) {
|
|
||||||
selectedCompany.value.name = companyForm.value.name;
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
notice.value = { type: 'danger', message: e.message };
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Employee Actions
|
|
||||||
function openCreateEmployee() {
|
|
||||||
isEditingEmployee.value = false;
|
|
||||||
employeeForm.value = { id: '', first_name: '', last_name: '', email: '', contact_person: null, temporary_password: '' };
|
|
||||||
drawerEmployeeActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEditEmployee(item: any) {
|
|
||||||
employeeForm.value = {
|
|
||||||
id: item.id || '',
|
|
||||||
first_name: item.first_name || '',
|
|
||||||
last_name: item.last_name || '',
|
|
||||||
email: item.email || '',
|
|
||||||
contact_person: item.contact_person || null,
|
|
||||||
temporary_password: item.temporary_password || ''
|
|
||||||
};
|
|
||||||
isEditingEmployee.value = true;
|
|
||||||
await nextTick();
|
|
||||||
drawerEmployeeActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEmployee() {
|
|
||||||
if (!employeeForm.value.email || !selectedCompany.value) return;
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
if (isEditingEmployee.value) {
|
|
||||||
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
|
|
||||||
first_name: employeeForm.value.first_name,
|
|
||||||
last_name: employeeForm.value.last_name,
|
|
||||||
email: employeeForm.value.email,
|
|
||||||
contact_person: employeeForm.value.contact_person
|
|
||||||
});
|
|
||||||
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
|
|
||||||
} else {
|
|
||||||
await api.post('/items/client_users', {
|
|
||||||
first_name: employeeForm.value.first_name,
|
|
||||||
last_name: employeeForm.value.last_name,
|
|
||||||
email: employeeForm.value.email,
|
|
||||||
company: selectedCompany.value.id,
|
|
||||||
contact_person: employeeForm.value.contact_person
|
|
||||||
});
|
|
||||||
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
|
|
||||||
}
|
|
||||||
drawerEmployeeActive.value = false;
|
|
||||||
await selectCompany(selectedCompany.value);
|
|
||||||
} catch (e: any) {
|
|
||||||
notice.value = { type: 'danger', message: e.message };
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function inviteUser(user: any) {
|
|
||||||
invitingId.value = user.id;
|
|
||||||
try {
|
|
||||||
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
|
|
||||||
notice.value = { type: 'success', message: `Zugangsdaten für ${user.first_name} versendet. 📧` };
|
|
||||||
await selectCompany(selectedCompany.value);
|
|
||||||
if (drawerEmployeeActive.value && employeeForm.value.id === user.id) {
|
|
||||||
const updated = employees.value.find(e => e.id === user.id);
|
|
||||||
if (updated) {
|
|
||||||
employeeForm.value.temporary_password = updated.temporary_password;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
notice.value = { type: 'danger', message: `Fehler: ${e.message}` };
|
|
||||||
} finally {
|
|
||||||
invitingId.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRowClick(event: any) {
|
|
||||||
const item = event?.item || event;
|
|
||||||
if (item && item.id) {
|
|
||||||
openEditEmployee(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
|
||||||
return new Date(dateStr).toLocaleString('de-DE', {
|
|
||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchCompanies();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; }
|
|
||||||
.company-item { cursor: pointer; }
|
|
||||||
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
|
|
||||||
.header-right { display: flex; gap: 12px; }
|
|
||||||
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; }
|
|
||||||
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; }
|
|
||||||
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
|
|
||||||
.user-cell { display: flex; align-items: center; gap: 12px; }
|
|
||||||
.user-name { font-weight: 600; }
|
|
||||||
.status-date { font-size: 12px; color: var(--theme--foreground-subdued); }
|
|
||||||
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
|
||||||
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
|
||||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
|
||||||
.field-note { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px; }
|
|
||||||
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.password-input :deep(textarea) {
|
|
||||||
font-family: var(--family-monospace);
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--theme--primary) !important;
|
|
||||||
background: var(--theme--background-subdued) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.clickable-table :deep(tbody tr) { cursor: pointer; transition: background-color 0.2s ease; }
|
|
||||||
.clickable-table :deep(tbody tr:hover) { background-color: var(--theme--background-subdued) !important; }
|
|
||||||
:deep(.v-list-item) { cursor: pointer !important; }
|
|
||||||
</style>
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,12 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "feedback-commander",
|
"name": "feedback-commander",
|
||||||
|
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||||
|
"icon": "view_kanban",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-extension-module"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"index.js"
|
||||||
|
],
|
||||||
"directus:extension": {
|
"directus:extension": {
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"path": "dist/index.js",
|
"path": "index.js",
|
||||||
"source": "src/index.ts",
|
"source": "src/index.ts",
|
||||||
"host": "^11.0.0"
|
"host": "*",
|
||||||
|
"name": "Feedback Commander"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "directus-extension build",
|
"build": "directus-extension build",
|
||||||
@@ -14,7 +24,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
"vue": "^3.4.34",
|
"vue": "^3.4.0"
|
||||||
"typescript": "^5.6.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { defineModule } from '@directus/extensions-sdk';
|
|
||||||
import ModuleComponent from './module.vue';
|
|
||||||
|
|
||||||
export default defineModule({
|
|
||||||
id: 'feedback-commander',
|
|
||||||
name: 'Feedback Commander',
|
|
||||||
icon: 'view_kanban',
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
component: ModuleComponent,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
<template>
|
|
||||||
<private-view title="Feedback Commander">
|
|
||||||
<template #headline>
|
|
||||||
<v-breadcrumb :items="[{ name: 'Feedback', to: '/feedback-commander' }]" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #title-outer:after>
|
|
||||||
<v-chip v-if="loading" label color="blue" small>Loading...</v-chip>
|
|
||||||
<v-chip v-else-if="fetchError" label color="red" small>Fetch Error</v-chip>
|
|
||||||
<v-chip v-else label color="green" small>{{ items.length }} Items</v-chip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #navigation>
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<v-text-overflow text="Websites" class="header-text" />
|
|
||||||
</div>
|
|
||||||
<v-list nav>
|
|
||||||
<v-list-item
|
|
||||||
:active="currentProject === 'all'"
|
|
||||||
@click="currentProject = 'all'"
|
|
||||||
clickable
|
|
||||||
>
|
|
||||||
<v-list-item-icon><v-icon name="language" /></v-list-item-icon>
|
|
||||||
<v-list-item-content><v-text-overflow text="All Projects" /></v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-list-item
|
|
||||||
v-for="project in projects"
|
|
||||||
:key="project"
|
|
||||||
:active="currentProject === project"
|
|
||||||
@click="currentProject = project"
|
|
||||||
clickable
|
|
||||||
>
|
|
||||||
<v-list-item-icon><v-icon name="public" color="var(--primary)" /></v-list-item-icon>
|
|
||||||
<v-list-item-content><v-text-overflow :text="project || 'Unknown'" /></v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="feedback-container">
|
|
||||||
<div v-if="!items.length && !loading && !fetchError" class="empty-state">
|
|
||||||
<v-info icon="inbox" title="Clean Inbox" center>
|
|
||||||
All feedback has been processed. Great job!
|
|
||||||
</v-info>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="fetchError" class="empty-state">
|
|
||||||
<v-info icon="error" title="Fetch Failed" :description="fetchError" center />
|
|
||||||
<v-button @click="fetchData" secondary small>Retry</v-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="operational-layout" v-else-if="items.length">
|
|
||||||
<!-- Detailed Triage Lane -->
|
|
||||||
<aside class="triage-lane">
|
|
||||||
<div class="lane-header">
|
|
||||||
<v-select
|
|
||||||
v-model="currentStatusFilter"
|
|
||||||
:items="statusOptions"
|
|
||||||
small
|
|
||||||
placeholder="Status Filter"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="lane-content scrollbar">
|
|
||||||
<TransitionGroup name="list">
|
|
||||||
<div
|
|
||||||
v-for="item in filteredItems"
|
|
||||||
:key="item.id"
|
|
||||||
class="feedback-card"
|
|
||||||
:class="{ active: selectedItem?.id === item.id }"
|
|
||||||
@click="selectItem(item)"
|
|
||||||
>
|
|
||||||
<div class="card-status-bar" :style="{ background: getStatusColor(item.status || 'open') }"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<header class="card-header">
|
|
||||||
<span class="card-user">{{ item.user_name }}</span>
|
|
||||||
<span class="card-date">{{ formatDate(item.date_created || item.id) }}</span>
|
|
||||||
</header>
|
|
||||||
<div class="card-text">{{ item.text }}</div>
|
|
||||||
<footer class="card-footer">
|
|
||||||
<div class="meta-tags">
|
|
||||||
<v-chip x-small outline>{{ item.project }}</v-chip>
|
|
||||||
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
|
|
||||||
</div>
|
|
||||||
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TransitionGroup>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Elaborated Master-Detail Desk -->
|
|
||||||
<main class="processing-desk scrollbar">
|
|
||||||
<Transition name="fade" mode="out-in">
|
|
||||||
<div v-if="selectedItem" :key="selectedItem.id" class="desk-content">
|
|
||||||
<header class="desk-header">
|
|
||||||
<div class="headline-group">
|
|
||||||
<div class="status-indicator">
|
|
||||||
<div class="status-dot" :style="{ background: getStatusColor(selectedItem.status || 'open') }"></div>
|
|
||||||
<span class="status-text">{{ capitalize(selectedItem.status || 'open') }}</span>
|
|
||||||
</div>
|
|
||||||
<h2>{{ selectedItem.user_name }}'s Submission</h2>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<v-select
|
|
||||||
v-model="selectedItem.contact_person"
|
|
||||||
:items="peopleOptions"
|
|
||||||
inline
|
|
||||||
placeholder="Bezugsperson..."
|
|
||||||
show-deselect
|
|
||||||
@update:model-value="updatePerson"
|
|
||||||
/>
|
|
||||||
<v-button primary @click="openDeepLink(selectedItem)">
|
|
||||||
<v-icon name="open_in_new" left /> Open & Highlight
|
|
||||||
</v-button>
|
|
||||||
<v-select
|
|
||||||
v-model="selectedItem.status"
|
|
||||||
:items="statuses"
|
|
||||||
inline
|
|
||||||
@update:model-value="updateStatus"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="desk-grid">
|
|
||||||
<!-- Message Container -->
|
|
||||||
<div class="main-column">
|
|
||||||
<v-card class="content-card">
|
|
||||||
<v-card-title>
|
|
||||||
<v-icon name="format_quote" left />
|
|
||||||
Feedback Content
|
|
||||||
</v-card-title>
|
|
||||||
<v-card-text class="feedback-body">
|
|
||||||
<div v-if="selectedItem.screenshot" class="visual-proof">
|
|
||||||
<label class="proof-label"><v-icon name="photo" x-small /> Element Snapshot</label>
|
|
||||||
<img :src="getAssetUrl(selectedItem.screenshot)" class="screenshot-img" />
|
|
||||||
</div>
|
|
||||||
<div class="main-text">{{ selectedItem.text }}</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<section class="reply-section">
|
|
||||||
<div class="section-divider">
|
|
||||||
<v-divider />
|
|
||||||
<span class="divider-label">Internal Communication</span>
|
|
||||||
<v-divider />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="thread">
|
|
||||||
<TransitionGroup name="thread-list">
|
|
||||||
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
|
|
||||||
<header class="reply-header">
|
|
||||||
<span class="reply-user">{{ reply.user_name }}</span>
|
|
||||||
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
|
|
||||||
</header>
|
|
||||||
<div class="reply-text">{{ reply.text }}</div>
|
|
||||||
</div>
|
|
||||||
</TransitionGroup>
|
|
||||||
<div v-if="!comments.length" class="empty-state-mini">
|
|
||||||
<v-icon name="auto_awesome" small /> No replies yet. Start the thread.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="composer">
|
|
||||||
<v-textarea v-model="replyText" placeholder="Compose internal response..." auto-grow />
|
|
||||||
<div class="composer-actions">
|
|
||||||
<v-button secondary :loading="sending" @click="sendReply">Post Reply</v-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Technical Sidebar -->
|
|
||||||
<aside class="meta-column">
|
|
||||||
<v-card class="meta-card">
|
|
||||||
<v-card-title>Context</v-card-title>
|
|
||||||
<v-card-text class="meta-list">
|
|
||||||
<div class="meta-item">
|
|
||||||
<label><v-icon name="public" x-small /> Website</label>
|
|
||||||
<strong>{{ selectedItem.project }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="meta-item">
|
|
||||||
<label><v-icon name="link" x-small /> Source Path</label>
|
|
||||||
<span class="truncate-path" :title="selectedItem.url">{{ formatUrl(selectedItem.url) }}</span>
|
|
||||||
<v-button icon small @click="openExternal(selectedItem.url)"><v-icon name="launch" /></v-button>
|
|
||||||
</div>
|
|
||||||
<v-divider />
|
|
||||||
<div class="meta-item">
|
|
||||||
<label><v-icon name="layers" x-small /> Element Trace</label>
|
|
||||||
<code class="trace-code">{{ selectedItem.selector || 'Body' }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="meta-item">
|
|
||||||
<label><v-icon name="location_searching" x-small /> Precise Mark</label>
|
|
||||||
<span class="coords">X: {{ Math.round(selectedItem.x) }}px / Y: {{ Math.round(selectedItem.y) }}px</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-item">
|
|
||||||
<label><v-icon name="fingerprint" x-small /> Reference ID</label>
|
|
||||||
<code class="id-code">{{ selectedItem.id }}</code>
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<div class="help-box">
|
|
||||||
<v-icon name="help_outline" x-small />
|
|
||||||
<span>Click "Open & Highlight" to jump directly to this element on the live site.</span>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="no-selection-desk">
|
|
||||||
<v-info icon="touch_app" title="Select Feedback" center>
|
|
||||||
Choose an entry from the triage list to view details and process.
|
|
||||||
</v-info>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</private-view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue';
|
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
const items = ref([]);
|
|
||||||
const comments = ref([]);
|
|
||||||
const people = ref([]);
|
|
||||||
const loading = ref(true);
|
|
||||||
const fetchError = ref(null);
|
|
||||||
const sending = ref(false);
|
|
||||||
const selectedItem = ref(null);
|
|
||||||
const currentProject = ref('all');
|
|
||||||
const currentStatusFilter = ref('open');
|
|
||||||
const replyText = ref('');
|
|
||||||
|
|
||||||
const statuses = [
|
|
||||||
{ text: 'Open', value: 'open', icon: 'warning', color: '#E91E63' },
|
|
||||||
{ text: 'In Progress', value: 'in_progress', icon: 'play_arrow', color: '#2196F3' },
|
|
||||||
{ text: 'Resolved', value: 'resolved', icon: 'check_circle', color: '#4CAF50' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ text: 'All Statuses', value: 'all' },
|
|
||||||
...statuses
|
|
||||||
];
|
|
||||||
|
|
||||||
const projects = computed(() => {
|
|
||||||
const projSet = new Set(items.value.map(i => i.project).filter(Boolean));
|
|
||||||
return Array.from(projSet).sort();
|
|
||||||
});
|
|
||||||
|
|
||||||
const peopleOptions = computed(() =>
|
|
||||||
people.value.map(p => ({
|
|
||||||
text: `${p.first_name} ${p.last_name}`,
|
|
||||||
value: p.id
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
async function fetchData() {
|
|
||||||
loading.value = true;
|
|
||||||
fetchError.value = null;
|
|
||||||
try {
|
|
||||||
const [feedbackRes, peopleRes] = await Promise.all([
|
|
||||||
api.get('/items/visual_feedback', {
|
|
||||||
params: {
|
|
||||||
sort: '-date_created,-id',
|
|
||||||
limit: 300
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
|
||||||
]);
|
|
||||||
items.value = feedbackRes.data.data;
|
|
||||||
people.value = peopleRes.data.data;
|
|
||||||
} catch (e: any) {
|
|
||||||
fetchError.value = e.message;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectItem(item) {
|
|
||||||
selectedItem.value = null;
|
|
||||||
setTimeout(async () => {
|
|
||||||
selectedItem.value = item;
|
|
||||||
comments.value = [];
|
|
||||||
try {
|
|
||||||
const response = await api.get('/items/visual_feedback_comments', {
|
|
||||||
params: {
|
|
||||||
filter: { feedback_id: { _eq: item.id } },
|
|
||||||
sort: '-date_created,-id'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
comments.value = response.data.data;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateStatus(val) {
|
|
||||||
if (!selectedItem.value) return;
|
|
||||||
try {
|
|
||||||
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
|
|
||||||
status: val
|
|
||||||
});
|
|
||||||
fetchData();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updatePerson(val) {
|
|
||||||
if (!selectedItem.value) return;
|
|
||||||
try {
|
|
||||||
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
|
|
||||||
contact_person: val
|
|
||||||
});
|
|
||||||
fetchData();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendReply() {
|
|
||||||
if (!replyText.value.trim() || !selectedItem.value) return;
|
|
||||||
sending.value = true;
|
|
||||||
try {
|
|
||||||
const response = await api.post('/items/visual_feedback_comments', {
|
|
||||||
feedback_id: selectedItem.value.id,
|
|
||||||
user_name: 'Operator',
|
|
||||||
text: replyText.value
|
|
||||||
});
|
|
||||||
comments.value.unshift(response.data.data);
|
|
||||||
replyText.value = '';
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
sending.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
if (!dateStr || typeof dateStr === 'number') return 'Legacy';
|
|
||||||
return new Date(dateStr).toLocaleDateString() + ' ' + new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUrl(url) {
|
|
||||||
if (!url) return '';
|
|
||||||
return url.replace(/^https?:\/\//, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function capitalize(s) {
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDeepLinkUrl(item) {
|
|
||||||
if (!item || !item.url) return '';
|
|
||||||
try {
|
|
||||||
const url = new URL(item.url);
|
|
||||||
url.searchParams.set('fb_id', item.id);
|
|
||||||
return url.toString();
|
|
||||||
} catch (e) {
|
|
||||||
return item.url + '?fb_id=' + item.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDeepLink(item) {
|
|
||||||
const url = getDeepLinkUrl(item);
|
|
||||||
if (url) window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openExternal(url) {
|
|
||||||
if (url) window.open(url, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAssetUrl(id) {
|
|
||||||
if (!id) return '';
|
|
||||||
return `/assets/${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusColor(status) {
|
|
||||||
const s = statuses.find(st => st.value === status);
|
|
||||||
return s ? s.color : 'var(--foreground-subdued)';
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchData();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.feedback-container {
|
|
||||||
height: calc(100vh - 64px);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--background-subdued);
|
|
||||||
}
|
|
||||||
|
|
||||||
.operational-layout {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Triage Lane Polish */
|
|
||||||
.triage-lane {
|
|
||||||
width: 360px;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--background-normal);
|
|
||||||
border-right: 1px solid var(--border-normal);
|
|
||||||
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lane-header {
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--background-normal);
|
|
||||||
border-bottom: 1px solid var(--border-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lane-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card {
|
|
||||||
background: var(--background-normal);
|
|
||||||
border: 1px solid var(--border-subdued);
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card:hover {
|
|
||||||
border-color: var(--border-normal);
|
|
||||||
background: var(--background-subdued);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-card.active {
|
|
||||||
border-color: var(--primary);
|
|
||||||
background: var(--background-accent);
|
|
||||||
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-status-bar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-user { font-weight: bold; color: var(--foreground-normal); }
|
|
||||||
.card-date { color: var(--foreground-subdued); }
|
|
||||||
|
|
||||||
.card-text {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--foreground-normal);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-tags {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Processing Desk Refinement */
|
|
||||||
.processing-desk {
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desk-content {
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desk-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
border-bottom: 2px solid var(--border-normal);
|
|
||||||
padding-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headline-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--foreground-subdued);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text { letter-spacing: 0.5px; }
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desk-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 300px;
|
|
||||||
gap: 24px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-card {
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback-body {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1.6;
|
|
||||||
padding: 24px;
|
|
||||||
color: var(--foreground-normal);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visual-proof {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proof-label {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--foreground-subdued);
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.screenshot-img {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--border-normal);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
||||||
background: var(--background-subdued);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-text {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-section {
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-divider {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider-label {
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--foreground-subdued);
|
|
||||||
white-space: nowrap;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-bubble {
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--background-normal);
|
|
||||||
border: 1px solid var(--border-subdued);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 11px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reply-user { font-weight: 800; color: var(--primary); }
|
|
||||||
.reply-date { color: var(--foreground-subdued); }
|
|
||||||
|
|
||||||
.reply-text { font-size: 14px; line-height: 1.5; }
|
|
||||||
|
|
||||||
.composer {
|
|
||||||
background: var(--background-normal);
|
|
||||||
border: 1px solid var(--border-normal);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-card {
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-item label {
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--foreground-subdued);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.truncate-path {
|
|
||||||
color: var(--primary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trace-code, .id-code {
|
|
||||||
background: var(--background-subdued);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coords { font-weight: bold; font-family: var(--family-monospace); }
|
|
||||||
|
|
||||||
.help-box {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 16px;
|
|
||||||
background: rgba(var(--primary-rgb), 0.05);
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--primary);
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-selection-desk {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-mini {
|
|
||||||
text-align: center;
|
|
||||||
padding: 24px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--foreground-subdued);
|
|
||||||
background: var(--background-subdued);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px dashed var(--border-normal);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
|
|
||||||
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-20px); }
|
|
||||||
|
|
||||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
|
||||||
.fade-enter-from { opacity: 0; transform: translateY(10px); }
|
|
||||||
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
|
|
||||||
|
|
||||||
.thread-list-enter-active { transition: all 0.4s ease; transform-origin: top; }
|
|
||||||
.thread-list-enter-from { opacity: 0; transform: scaleY(0.9); }
|
|
||||||
|
|
||||||
.scrollbar::-webkit-scrollbar { width: 6px; }
|
|
||||||
.scrollbar::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.scrollbar::-webkit-scrollbar-thumb { background: var(--border-subdued); border-radius: 3px; }
|
|
||||||
.scrollbar::-webkit-scrollbar-thumb:hover { background: var(--border-normal); }
|
|
||||||
</style>
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "people-manager",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "module",
|
|
||||||
"path": "dist/index.js",
|
|
||||||
"source": "src/index.ts",
|
|
||||||
"host": "^11.0.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "directus-extension build",
|
|
||||||
"dev": "directus-extension build -w"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@directus/extensions-sdk": "11.0.2",
|
|
||||||
"vue": "^3.4.34",
|
|
||||||
"typescript": "^5.6.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { defineModule } from '@directus/extensions-sdk';
|
|
||||||
import ModuleComponent from './module.vue';
|
|
||||||
|
|
||||||
export default defineModule({
|
|
||||||
id: 'people-manager',
|
|
||||||
name: 'People Manager',
|
|
||||||
icon: 'person',
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
component: ModuleComponent,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
<template>
|
|
||||||
<private-view title="People Manager">
|
|
||||||
<template #navigation>
|
|
||||||
<v-list nav>
|
|
||||||
<v-list-item @click="openCreateDrawer" clickable>
|
|
||||||
<v-list-item-icon>
|
|
||||||
<v-icon name="add" color="var(--theme--primary)" />
|
|
||||||
</v-list-item-icon>
|
|
||||||
<v-list-item-content>
|
|
||||||
<v-text-overflow text="Neue Person anlegen" />
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-list-item
|
|
||||||
v-for="person in people"
|
|
||||||
:key="person.id"
|
|
||||||
:active="selectedPerson?.id === person.id"
|
|
||||||
class="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</span>
|
|
||||||
<p class="value">{{ selectedPerson.company || '---' }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<span class="label">Telefon</span>
|
|
||||||
<p class="value">{{ selectedPerson.phone || '---' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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">Organisation / Firma</span>
|
|
||||||
<v-input v-model="form.company" 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>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
const people = 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: '',
|
|
||||||
phone: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchPeople() {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/items/people', {
|
|
||||||
params: { sort: 'last_name' }
|
|
||||||
});
|
|
||||||
people.ref = response.data.data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch people:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPerson(person) {
|
|
||||||
selectedPerson.value = person;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreateDrawer() {
|
|
||||||
isEditing.value = false;
|
|
||||||
form.value = { id: null, first_name: '', last_name: '', email: '', company: '', phone: '' };
|
|
||||||
drawerActive.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditDrawer() {
|
|
||||||
isEditing.value = true;
|
|
||||||
form.value = { ...selectedPerson.value };
|
|
||||||
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 fetchPeople();
|
|
||||||
if (isEditing.value) {
|
|
||||||
selectedPerson.value = form.value;
|
|
||||||
}
|
|
||||||
} 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 fetchPeople();
|
|
||||||
} catch (error) {
|
|
||||||
feedback.value = { type: 'danger', message: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(fetchPeople);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.content-wrapper {
|
|
||||||
padding: 32px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 800;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
color: var(--theme--foreground-subdued);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-right {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details-grid {
|
|
||||||
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>
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cms-infra",
|
"name": "@mintel/cms-infra",
|
||||||
"version": "1.7.0",
|
"version": "1.7.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm --filter \"./extensions/**\" build",
|
|
||||||
"dev": "pnpm --filter \"./extensions/**\" dev",
|
|
||||||
"up": "docker compose up -d",
|
"up": "docker compose up -d",
|
||||||
"up:build": "docker compose up -d --build",
|
|
||||||
"down": "docker compose down",
|
"down": "docker compose down",
|
||||||
"logs": "docker compose logs -f"
|
"logs": "docker compose logs -f"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export default (router) => {
|
|
||||||
router.get('/ping', (req, res) => res.send('pong'));
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "test-extension",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"directus:extension": {
|
|
||||||
"type": "endpoint",
|
|
||||||
"path": "index.js",
|
|
||||||
"host": "^11.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -98,202 +98,7 @@ collections:
|
|||||||
versioning: false
|
versioning: false
|
||||||
schema:
|
schema:
|
||||||
name: visual_feedback_comments
|
name: visual_feedback_comments
|
||||||
- collection: leads
|
|
||||||
meta:
|
|
||||||
accountability: all
|
|
||||||
archive_app_filter: true
|
|
||||||
collapse: open
|
|
||||||
collection: leads
|
|
||||||
color: '#4CAF50'
|
|
||||||
display_template: '{{company_name}} ({{status}})'
|
|
||||||
group: null
|
|
||||||
hidden: false
|
|
||||||
icon: auto_awesome
|
|
||||||
note: "Leads for automated acquisition"
|
|
||||||
singleton: false
|
|
||||||
schema:
|
|
||||||
name: leads
|
|
||||||
fields:
|
fields:
|
||||||
- collection: leads
|
|
||||||
field: id
|
|
||||||
type: uuid
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: id
|
|
||||||
hidden: true
|
|
||||||
interface: input
|
|
||||||
readonly: true
|
|
||||||
special:
|
|
||||||
- uuid
|
|
||||||
schema:
|
|
||||||
name: id
|
|
||||||
table: leads
|
|
||||||
data_type: uuid
|
|
||||||
is_primary_key: true
|
|
||||||
- collection: leads
|
|
||||||
field: company_name
|
|
||||||
type: string
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: company_name
|
|
||||||
interface: input
|
|
||||||
width: half
|
|
||||||
schema:
|
|
||||||
name: company_name
|
|
||||||
table: leads
|
|
||||||
data_type: varchar
|
|
||||||
max_length: 255
|
|
||||||
- collection: leads
|
|
||||||
field: website_url
|
|
||||||
type: string
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: website_url
|
|
||||||
interface: input
|
|
||||||
width: half
|
|
||||||
schema:
|
|
||||||
name: website_url
|
|
||||||
table: leads
|
|
||||||
data_type: varchar
|
|
||||||
max_length: 255
|
|
||||||
- collection: leads
|
|
||||||
field: contact_name
|
|
||||||
type: string
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: contact_name
|
|
||||||
interface: input
|
|
||||||
width: half
|
|
||||||
schema:
|
|
||||||
name: contact_name
|
|
||||||
table: leads
|
|
||||||
data_type: varchar
|
|
||||||
max_length: 255
|
|
||||||
- collection: leads
|
|
||||||
field: contact_email
|
|
||||||
type: string
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: contact_email
|
|
||||||
interface: input
|
|
||||||
width: half
|
|
||||||
schema:
|
|
||||||
name: contact_email
|
|
||||||
table: leads
|
|
||||||
data_type: varchar
|
|
||||||
max_length: 255
|
|
||||||
- collection: leads
|
|
||||||
field: status
|
|
||||||
type: string
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: status
|
|
||||||
interface: select-dropdown
|
|
||||||
options:
|
|
||||||
choices:
|
|
||||||
- text: New
|
|
||||||
value: new
|
|
||||||
- text: Auditing
|
|
||||||
value: auditing
|
|
||||||
- text: Audit Ready
|
|
||||||
value: audit_ready
|
|
||||||
- text: Contacted
|
|
||||||
value: contacted
|
|
||||||
- text: Follow-up
|
|
||||||
value: follow_up
|
|
||||||
- text: Responding
|
|
||||||
value: responding
|
|
||||||
- text: Converted
|
|
||||||
value: converted
|
|
||||||
- text: Lost
|
|
||||||
value: lost
|
|
||||||
width: half
|
|
||||||
schema:
|
|
||||||
name: status
|
|
||||||
table: leads
|
|
||||||
data_type: varchar
|
|
||||||
default_value: new
|
|
||||||
max_length: 50
|
|
||||||
- collection: leads
|
|
||||||
field: briefing
|
|
||||||
type: text
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: briefing
|
|
||||||
interface: input-multiline
|
|
||||||
width: full
|
|
||||||
schema:
|
|
||||||
name: briefing
|
|
||||||
table: leads
|
|
||||||
data_type: text
|
|
||||||
- collection: leads
|
|
||||||
field: comments
|
|
||||||
type: text
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: comments
|
|
||||||
interface: input-multiline
|
|
||||||
width: full
|
|
||||||
schema:
|
|
||||||
name: comments
|
|
||||||
table: leads
|
|
||||||
data_type: text
|
|
||||||
- collection: leads
|
|
||||||
field: ai_state
|
|
||||||
type: json
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: ai_state
|
|
||||||
interface: input-code
|
|
||||||
options:
|
|
||||||
language: json
|
|
||||||
width: full
|
|
||||||
schema:
|
|
||||||
name: ai_state
|
|
||||||
table: leads
|
|
||||||
data_type: json
|
|
||||||
- collection: leads
|
|
||||||
field: audit_context
|
|
||||||
type: text
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: audit_context
|
|
||||||
interface: input-multiline
|
|
||||||
width: full
|
|
||||||
schema:
|
|
||||||
name: audit_context
|
|
||||||
table: leads
|
|
||||||
data_type: text
|
|
||||||
- collection: leads
|
|
||||||
field: date_created
|
|
||||||
type: timestamp
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: date_created
|
|
||||||
interface: datetime
|
|
||||||
readonly: true
|
|
||||||
special:
|
|
||||||
- date-created
|
|
||||||
width: half
|
|
||||||
schema:
|
|
||||||
name: date_created
|
|
||||||
table: leads
|
|
||||||
data_type: datetime
|
|
||||||
- collection: leads
|
|
||||||
field: date_updated
|
|
||||||
type: timestamp
|
|
||||||
meta:
|
|
||||||
collection: leads
|
|
||||||
field: date_updated
|
|
||||||
interface: datetime
|
|
||||||
readonly: true
|
|
||||||
special:
|
|
||||||
- date-updated
|
|
||||||
width: half
|
|
||||||
schema:
|
|
||||||
name: date_updated
|
|
||||||
table: leads
|
|
||||||
data_type: datetime
|
|
||||||
- collection: client_users
|
- collection: client_users
|
||||||
field: id
|
field: id
|
||||||
type: uuid
|
type: uuid
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
API_URL="http://localhost:8059"
|
|
||||||
EMAIL="marc@mintel.me"
|
|
||||||
PASSWORD="Tim300493."
|
|
||||||
|
|
||||||
echo "Logging in to Directus..."
|
|
||||||
TOKEN=$(curl -s -X POST "${API_URL}/auth/login" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"email\":\"${EMAIL}\", \"password\":\"${PASSWORD}\"}" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
|
|
||||||
|
|
||||||
if [ -z "$TOKEN" ]; then
|
|
||||||
echo "Login failed!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Hiding 'leads' collection..."
|
|
||||||
curl -s -X PATCH "${API_URL}/collections/leads" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"meta": {"hidden": true}}'
|
|
||||||
|
|
||||||
echo "Creating 'people' collection..."
|
|
||||||
curl -s -X POST "${API_URL}/collections" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"collection": "people",
|
|
||||||
"schema": {},
|
|
||||||
"meta": {
|
|
||||||
"icon": "person",
|
|
||||||
"display_template": "{{first_name}} {{last_name}}",
|
|
||||||
"show_status_indicator": true
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
|
|
||||||
echo "Adding fields to 'people'..."
|
|
||||||
FIELDS='[
|
|
||||||
{"field": "first_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
|
||||||
{"field": "last_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
|
||||||
{"field": "email", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
|
||||||
{"field": "phone", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
|
||||||
{"field": "company", "type": "string", "meta": {"interface": "input", "width": "full"}}
|
|
||||||
]'
|
|
||||||
|
|
||||||
for field in $(echo "${FIELDS}" | jq -c '.[]'); do
|
|
||||||
curl -s -X POST "${API_URL}/fields/people" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "${field}"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Adding 'contact_person' to 'leads', 'client_users', and 'visual_feedback'..."
|
|
||||||
for collection in leads client_users visual_feedback; do
|
|
||||||
curl -s -X POST "${API_URL}/fields/${collection}" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"field": "contact_person",
|
|
||||||
"type": "uuid",
|
|
||||||
"meta": {
|
|
||||||
"interface": "select-dropdown-m2o",
|
|
||||||
"options": {
|
|
||||||
"template": "{{first_name}} {{last_name}}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"schema": {
|
|
||||||
"foreign_key_column": "id",
|
|
||||||
"foreign_key_table": "people"
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Done!"
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -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.0",
|
"version": "1.7.3",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
"directus-extension",
|
"directus-extension",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/eslint-config",
|
"name": "@mintel/eslint-config",
|
||||||
"version": "1.7.0",
|
"version": "1.7.3",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@mintel/extension-feedback-commander",
|
"name": "@mintel/extension-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.0",
|
"version": "1.7.3",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
"directus-extension",
|
"directus-extension",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/gatekeeper",
|
"name": "@mintel/gatekeeper",
|
||||||
"version": "1.7.0",
|
"version": "1.7.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/husky-config",
|
"name": "@mintel/husky-config",
|
||||||
"version": "1.7.0",
|
"version": "1.7.3",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Start from the pre-built Nextjs Base image
|
# Start from the pre-built Nextjs Base image
|
||||||
ARG IMAGE_TAG=latest
|
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||||
FROM registry.infra.mintel.me/mintel/nextjs:${IMAGE_TAG} AS builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
|
|||||||
RUN pnpm --filter ${APP_NAME:-app} build
|
RUN pnpm --filter ${APP_NAME:-app} build
|
||||||
|
|
||||||
# Production runner image
|
# Production runner image
|
||||||
FROM registry.infra.mintel.me/mintel/runtime:${IMAGE_TAG} AS runner
|
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy standalone output and static files
|
# Copy standalone output and static files
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/infra",
|
"name": "@mintel/infra",
|
||||||
"version": "1.7.0",
|
"version": "1.7.3",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/mail",
|
"name": "@mintel/mail",
|
||||||
"version": "1.7.0",
|
"version": "1.7.3",
|
||||||
"private": false,
|
"private": false,
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
@@ -24,8 +24,7 @@
|
|||||||
"build": "tsup src/index.ts src/templates/*.tsx --format esm --dts --clean",
|
"build": "tsup src/index.ts src/templates/*.tsx --format esm --dts --clean",
|
||||||
"dev": "tsup src/index.ts src/templates/*.tsx --format esm --watch --dts",
|
"dev": "tsup src/index.ts src/templates/*.tsx --format esm --watch --dts",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"test": "vitest run",
|
"test": "vitest run"
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "^0.0.33"
|
"@react-email/components": "^0.0.33"
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
export * from "./templates/ConfirmationMessage";
|
import { render as reactEmailRender } from "@react-email/components";
|
||||||
export * from "./templates/ContactFormNotification";
|
import { ReactElement } from "react";
|
||||||
export * from "./templates/SiteAuditTemplate";
|
|
||||||
export * from "./templates/FollowUpTemplate";
|
/**
|
||||||
export * from "./templates/ProjectEstimateTemplate";
|
* Renders a React email template to HTML.
|
||||||
|
*/
|
||||||
|
export async function render(
|
||||||
|
template: ReactElement,
|
||||||
|
options?: any,
|
||||||
|
): Promise<string> {
|
||||||
|
return reactEmailRender(template, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export Components
|
||||||
|
export * from "./components/MintelLogo";
|
||||||
|
|
||||||
|
// Export Layouts
|
||||||
|
export * from "./layouts/BaseLayout";
|
||||||
export * from "./layouts/MintelLayout";
|
export * from "./layouts/MintelLayout";
|
||||||
export { render } from "@react-email/components";
|
export * from "./layouts/ClientLayout";
|
||||||
|
|
||||||
|
// Export Templates
|
||||||
|
export * from "./templates/ContactFormNotification";
|
||||||
|
export * from "./templates/ConfirmationMessage";
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
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",
|
|
||||||
};
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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",
|
|
||||||
};
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
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.0",
|
"version": "1.7.3",
|
||||||
"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.0",
|
"version": "1.7.3",
|
||||||
"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-observability",
|
"name": "@mintel/next-observability",
|
||||||
"version": "1.7.0",
|
"version": "1.7.3",
|
||||||
"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.0",
|
"version": "1.7.5",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -12,13 +12,35 @@ export type MintelDirectusClient = DirectusClient<any> &
|
|||||||
AuthenticationClient<any>;
|
AuthenticationClient<any>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a Directus client configured with Mintel standards
|
* Creates a Directus client configured with Mintel standards.
|
||||||
|
* Automatically handles internal vs. external URLs based on environment.
|
||||||
*/
|
*/
|
||||||
export function createMintelDirectusClient(url?: string): MintelDirectusClient {
|
export function createMintelDirectusClient(url?: string): MintelDirectusClient {
|
||||||
const directusUrl =
|
const isServer = typeof window === "undefined";
|
||||||
url || process.env.DIRECTUS_URL || "http://localhost:8055";
|
|
||||||
|
|
||||||
return createDirectus(directusUrl).with(rest()).with(authentication());
|
// 1. If an explicit URL is provided, use it.
|
||||||
|
if (url) {
|
||||||
|
return createDirectus(url).with(rest()).with(authentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. On server: Prioritize INTERNAL_DIRECTUS_URL, fallback to DIRECTUS_URL
|
||||||
|
if (isServer) {
|
||||||
|
const directusUrl =
|
||||||
|
process.env.INTERNAL_DIRECTUS_URL ||
|
||||||
|
process.env.DIRECTUS_URL ||
|
||||||
|
"http://localhost:8055";
|
||||||
|
return createDirectus(directusUrl).with(rest()).with(authentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. In browser: Use a proxy path if we are on a different origin,
|
||||||
|
// or use the current origin if no DIRECTUS_URL is set.
|
||||||
|
const proxyPath = "/api/directus"; // Standard Mintel proxy path
|
||||||
|
const browserUrl =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? `${window.location.origin}${proxyPath}`
|
||||||
|
: proxyPath;
|
||||||
|
|
||||||
|
return createDirectus(browserUrl).with(rest()).with(authentication());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ export const mintelEnvSchema = {
|
|||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "production", "test"])
|
.enum(["development", "production", "test"])
|
||||||
.default("development"),
|
.default("development"),
|
||||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
NEXT_PUBLIC_BASE_URL: z.string().url().optional(),
|
||||||
|
NEXT_PUBLIC_TARGET: z
|
||||||
|
.enum(["development", "testing", "staging", "production"])
|
||||||
|
.optional(),
|
||||||
|
TARGET: z
|
||||||
|
.enum(["development", "testing", "staging", "production"])
|
||||||
|
.optional(),
|
||||||
|
|
||||||
// Analytics (Proxy Pattern)
|
// Analytics (Proxy Pattern)
|
||||||
UMAMI_WEBSITE_ID: z.string().optional(),
|
UMAMI_WEBSITE_ID: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
|
||||||
UMAMI_API_ENDPOINT: z
|
UMAMI_API_ENDPOINT: z
|
||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
@@ -23,6 +30,8 @@ export const mintelEnvSchema = {
|
|||||||
LOG_LEVEL: z
|
LOG_LEVEL: z
|
||||||
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
||||||
.default("info"),
|
.default("info"),
|
||||||
|
|
||||||
|
// Mail
|
||||||
MAIL_HOST: z.string().optional(),
|
MAIL_HOST: z.string().optional(),
|
||||||
MAIL_PORT: z.coerce.number().default(587),
|
MAIL_PORT: z.coerce.number().default(587),
|
||||||
MAIL_USERNAME: z.string().optional(),
|
MAIL_USERNAME: z.string().optional(),
|
||||||
@@ -32,13 +41,19 @@ export const mintelEnvSchema = {
|
|||||||
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
|
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
|
||||||
z.array(z.string()).default([]),
|
z.array(z.string()).default([]),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Directus
|
||||||
|
DIRECTUS_URL: z.string().url().default("http://localhost:8055"),
|
||||||
|
DIRECTUS_ADMIN_EMAIL: z.string().optional(),
|
||||||
|
DIRECTUS_ADMIN_PASSWORD: z.string().optional(),
|
||||||
|
DIRECTUS_API_TOKEN: z.string().optional(),
|
||||||
|
INTERNAL_DIRECTUS_URL: z.string().url().optional(),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function validateMintelEnv(schemaExtension = {}) {
|
export function validateMintelEnv<
|
||||||
const fullSchema = z.object({
|
T extends z.ZodRawShape = Record<string, never>,
|
||||||
...mintelEnvSchema,
|
>(schemaExtension: T = {} as T) {
|
||||||
...schemaExtension,
|
const fullSchema = z.object(mintelEnvSchema).extend(schemaExtension);
|
||||||
});
|
|
||||||
|
|
||||||
const isBuildTime =
|
const isBuildTime =
|
||||||
process.env.NEXT_PHASE === "phase-production-build" ||
|
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||||
@@ -51,7 +66,7 @@ export function validateMintelEnv(schemaExtension = {}) {
|
|||||||
console.warn(
|
console.warn(
|
||||||
"⚠️ Some environment variables are missing during build, but skipping strict validation.",
|
"⚠️ Some environment variables are missing during build, but skipping strict validation.",
|
||||||
);
|
);
|
||||||
// Return partial data to allow build to continue
|
// Return process.env casted to the full schema type to unblock builds
|
||||||
return process.env as unknown as z.infer<typeof fullSchema>;
|
return process.env as unknown as z.infer<typeof fullSchema>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/observability",
|
"name": "@mintel/observability",
|
||||||
"version": "1.7.0",
|
"version": "1.7.3",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/tsconfig",
|
"name": "@mintel/tsconfig",
|
||||||
"version": "1.7.0",
|
"version": "1.7.3",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
1675
pnpm-lock.yaml
generated
1675
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
|||||||
packages:
|
packages:
|
||||||
- 'packages/*'
|
- 'packages/*'
|
||||||
- 'packages/cms-infra/extensions/*'
|
|
||||||
- 'apps/*'
|
- 'apps/*'
|
||||||
- '../klz-2026'
|
|
||||||
|
|||||||
Reference in New Issue
Block a user