feat: implement Project Management with Gantt Chart, Milestones, and CRM enhancements

This commit is contained in:
2026-03-01 00:26:59 +01:00
parent 4b5609a75e
commit 6444cf1e81
47 changed files with 15312 additions and 7373 deletions

View File

@@ -1,4 +1,5 @@
import type { CollectionConfig } from "payload";
import { aiEndpointHandler } from "../endpoints/aiEndpoint";
export const CrmAccounts: CollectionConfig = {
slug: "crm-accounts",
@@ -10,7 +11,16 @@ export const CrmAccounts: CollectionConfig = {
useAsTitle: "name",
defaultColumns: ["name", "status", "leadTemperature", "updatedAt"],
group: "CRM",
description:
"Accounts represent companies or organizations. They are the central hub linking Contacts and Interactions together. Use this to track the overall relationship status.",
},
endpoints: [
{
path: "/:id/analyze",
method: "post",
handler: aiEndpointHandler,
},
],
access: {
read: ({ req: { user } }) => Boolean(user), // Admin only
create: ({ req: { user } }) => Boolean(user),
@@ -23,7 +33,7 @@ export const CrmAccounts: CollectionConfig = {
type: "ui",
admin: {
components: {
Field: "/src/payload/components/AiAnalyzeButton#AiAnalyzeButton",
Field: "@/src/payload/components/AiAnalyzeButton#AiAnalyzeButton",
},
},
},
@@ -31,14 +41,20 @@ export const CrmAccounts: CollectionConfig = {
name: "name",
type: "text",
required: true,
label: "Company / Account Name",
label: "Company / Project Name",
admin: {
description:
"Enter the official name of the business or the research project name.",
},
},
{
name: "website",
type: "text",
label: "Website URL",
admin: {
description: "The website of the account, useful for AI analysis.",
description:
"The main website of the account. Required for triggering the AI Website Analysis.",
placeholder: "https://example.com",
},
},
{
@@ -48,29 +64,31 @@ export const CrmAccounts: CollectionConfig = {
name: "status",
type: "select",
options: [
{ label: "Lead", value: "lead" },
{ label: "Client", value: "client" },
{ label: "Lost", value: "lost" },
{ label: "Lead (Prospect)", value: "lead" },
{ label: "Active Client", value: "client" },
{ label: "Business Partner", value: "partner" },
{ label: "Lost / Archive", value: "lost" },
],
defaultValue: "lead",
admin: {
width: "50%",
description: "Change from Lead to Client upon conversion.",
description: "Current lifecycle stage of this business relation.",
},
},
{
name: "leadTemperature",
type: "select",
options: [
{ label: "Cold", value: "cold" },
{ label: "Warm", value: "warm" },
{ label: "Hot", value: "hot" },
{ label: "❄️ Cold (New Research)", value: "cold" },
{ label: "🔥 Warm (In Contact)", value: "warm" },
{ label: "Hot (Negotiation / Quote)", value: "hot" },
],
admin: {
condition: (data) => {
return data?.status === "lead";
},
width: "50%",
description: "Indicates how likely this lead is to convert soon.",
},
},
],
@@ -79,7 +97,10 @@ export const CrmAccounts: CollectionConfig = {
name: "assignedTo",
type: "relationship",
relationTo: "users",
label: "Assigned To (User)",
label: "Account Manager (User)",
admin: {
description: "The internal team member responsible for this account.",
},
},
{
name: "reports",
@@ -89,7 +110,45 @@ export const CrmAccounts: CollectionConfig = {
label: "AI Reports & Documents",
admin: {
description:
"PDFs and strategy documents generated by AI or attached manually.",
"All generated PDF estimates and strategy documents appear here.",
},
},
{
name: "topics",
type: "join",
collection: "crm-topics",
on: "account",
admin: {
description:
"Projects, deals, or specific topics active for this client.",
},
},
{
name: "contacts",
type: "join",
collection: "crm-contacts",
on: "account",
admin: {
description: "All contacts associated with this account.",
},
},
{
name: "interactions",
type: "join",
collection: "crm-interactions",
on: "account",
admin: {
description:
"Timeline of all communication logged against this account.",
},
},
{
name: "projects",
type: "join",
collection: "projects",
on: "account",
admin: {
description: "All high-level projects associated with this account.",
},
},
],

View File

@@ -7,9 +7,11 @@ export const CrmContacts: CollectionConfig = {
plural: "Contacts",
},
admin: {
useAsTitle: "email", // Fallback, will define an afterRead hook or virtual field for a better title
defaultColumns: ["firstName", "lastName", "email", "account"],
useAsTitle: "fullName",
defaultColumns: ["fullName", "email", "account"],
group: "CRM",
description:
"Contacts are the individual people linked to an Account. A person should only be created once and can be assigned to a company here.",
},
access: {
read: ({ req: { user } }) => Boolean(user),
@@ -17,7 +19,36 @@ export const CrmContacts: CollectionConfig = {
update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => Boolean(user),
},
hooks: {
beforeChange: [
({ data }) => {
if (data?.firstName || data?.lastName) {
data.fullName =
`${data.firstName || ""} ${data.lastName || ""}`.trim();
}
return data;
},
],
afterRead: [
({ doc }) => {
if (!doc.fullName && (doc.firstName || doc.lastName)) {
return {
...doc,
fullName: `${doc.firstName || ""} ${doc.lastName || ""}`.trim(),
};
}
return doc;
},
],
},
fields: [
{
name: "fullName",
type: "text",
admin: {
hidden: true,
},
},
{
type: "row",
fields: [
@@ -44,6 +75,9 @@ export const CrmContacts: CollectionConfig = {
type: "email",
required: true,
unique: true,
admin: {
description: "Primary email address for communication tracking.",
},
},
{
type: "row",
@@ -60,6 +94,7 @@ export const CrmContacts: CollectionConfig = {
type: "text",
admin: {
width: "50%",
placeholder: "https://linkedin.com/in/...",
},
},
],
@@ -68,12 +103,29 @@ export const CrmContacts: CollectionConfig = {
name: "role",
type: "text",
label: "Job Title / Role",
admin: {
description: "e.g. CEO, Marketing Manager, Technical Lead",
},
},
{
name: "account",
type: "relationship",
relationTo: "crm-accounts",
label: "Company / Account",
admin: {
description:
"Link this person to an organization from the Accounts collection.",
},
},
{
name: "interactions",
type: "join",
collection: "crm-interactions",
on: "contact",
admin: {
description:
"Timeline of all communication logged directly with this person.",
},
},
],
};

View File

@@ -1,16 +1,18 @@
import type { CollectionConfig } from "payload";
import { sendEmailOnOutboundInteraction } from "../hooks/sendEmailOnOutboundInteraction";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
export const CrmInteractions: CollectionConfig = {
slug: "crm-interactions",
labels: {
singular: "Interaction",
plural: "Interactions",
singular: "Journal Entry",
plural: "Journal",
},
admin: {
useAsTitle: "subject",
defaultColumns: ["type", "direction", "subject", "date", "contact"],
defaultColumns: ["type", "subject", "date", "contact", "account"],
group: "CRM",
description:
"Your CRM journal. Log what happened, when, on which channel, and attach any relevant files. This is for summaries and facts — not for sending messages.",
},
access: {
read: ({ req: { user } }) => Boolean(user),
@@ -18,9 +20,6 @@ export const CrmInteractions: CollectionConfig = {
update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => Boolean(user),
},
hooks: {
afterChange: [sendEmailOnOutboundInteraction],
},
fields: [
{
type: "row",
@@ -28,25 +27,76 @@ export const CrmInteractions: CollectionConfig = {
{
name: "type",
type: "select",
label: "Channel",
options: [
{ label: "Email", value: "email" },
{ label: "Call", value: "call" },
{ label: "Meeting", value: "meeting" },
{ label: "Note", value: "note" },
{ label: "📧 Email", value: "email" },
{ label: "📞 Phone Call", value: "call" },
{ label: "🤝 Meeting", value: "meeting" },
{ label: "📱 WhatsApp", value: "whatsapp" },
{ label: "🌐 Social Media", value: "social" },
{ label: "📄 Document / File", value: "document" },
{ label: "📝 Internal Note", value: "note" },
],
required: true,
defaultValue: "email",
defaultValue: "note",
admin: {
width: "50%",
description: "Where did this communication take place?",
},
},
{
name: "direction",
type: "select",
options: [
{ label: "Inbound", value: "inbound" },
{ label: "Outbound", value: "outbound" },
{ label: "📥 Incoming (from Client)", value: "inbound" },
{ label: "📤 Outgoing (to Client)", value: "outbound" },
],
admin: {
hidden: true, // Hide from UI to prevent usage, but keep in DB schema to avoid Drizzle prompts
},
},
{
name: "date",
type: "date",
required: true,
defaultValue: () => new Date().toISOString(),
admin: {
width: "50%",
date: {
pickerAppearance: "dayAndTime",
},
description: "When did this happen?",
},
},
],
},
{
name: "subject",
type: "text",
required: true,
label: "Subject / Title",
admin: {
placeholder: "e.g. Herr X hat Website-Relaunch beauftragt",
},
},
{
type: "row",
fields: [
{
name: "contact",
type: "relationship",
relationTo: "crm-contacts",
label: "Contact Person",
admin: {
width: "50%",
description: "Who was involved?",
},
},
{
name: "account",
type: "relationship",
relationTo: "crm-accounts",
label: "Company / Account",
admin: {
width: "50%",
},
@@ -54,37 +104,40 @@ export const CrmInteractions: CollectionConfig = {
],
},
{
name: "date",
type: "date",
required: true,
defaultValue: () => new Date().toISOString(),
name: "topic",
type: "relationship",
relationTo: "crm-topics",
label: "Related Topic",
admin: {
date: {
pickerAppearance: "dayAndTime",
description:
"Optional: Group this entry under a specific project or topic.",
condition: (data) => {
return Boolean(data?.account);
},
},
},
{
name: "contact",
type: "relationship",
relationTo: "crm-contacts",
label: "Contact Person",
},
{
name: "account",
type: "relationship",
relationTo: "crm-accounts",
label: "Account / Company",
},
{
name: "subject",
type: "text",
required: true,
},
{
name: "content",
type: "richText",
label: "Content / Notes / Email Body",
label: "Summary / Notes",
editor: lexicalEditor({
features: ({ defaultFeatures }) => [...defaultFeatures],
}),
admin: {
description:
"Summarize what happened, what was decided, or what the next steps are.",
},
},
{
name: "attachments",
type: "relationship",
relationTo: "media",
hasMany: true,
label: "Attachments",
admin: {
description:
"Attach received documents, screenshots, contracts, or any relevant files.",
},
},
],
};

View File

@@ -0,0 +1,78 @@
import type { CollectionConfig } from "payload";
export const CrmTopics: CollectionConfig = {
slug: "crm-topics",
labels: {
singular: "Topic",
plural: "Topics",
},
admin: {
useAsTitle: "title",
defaultColumns: ["title", "account", "status"],
group: "CRM",
description:
"Group your interactions (emails, calls, notes) into Topics. This helps you keep track of specific projects with a client.",
},
access: {
read: ({ req: { user } }) => Boolean(user),
create: ({ req: { user } }) => Boolean(user),
update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => Boolean(user),
},
fields: [
{
name: "title",
type: "text",
required: true,
label: "Topic Name",
admin: {
placeholder: "e.g. Website Relaunch 2026",
},
},
{
name: "account",
type: "relationship",
relationTo: "crm-accounts",
required: true,
label: "Client / Account",
admin: {
description: "Which account does this topic belong to?",
},
},
{
name: "status",
type: "select",
options: [
{ label: "🟢 Active / Open", value: "active" },
{ label: "🟡 On Hold", value: "paused" },
{ label: "🔴 Closed / Won", value: "won" },
{ label: "⚫ Closed / Lost", value: "lost" },
],
defaultValue: "active",
required: true,
},
{
name: "stage",
type: "select",
options: [
{ label: "Discovery / Briefing", value: "discovery" },
{ label: "Proposal / Quote sent", value: "proposal" },
{ label: "Negotiation", value: "negotiation" },
{ label: "Implementation", value: "implementation" },
],
admin: {
description: "Optional: What stage is this deal/project currently in?",
},
},
{
name: "interactions",
type: "join",
collection: "crm-interactions",
on: "topic",
admin: {
description:
"Timeline of all emails and notes specifically related to this topic.",
},
},
],
};

View File

@@ -1,4 +1,5 @@
import type { CollectionConfig } from "payload";
import { convertInquiryEndpoint } from "../endpoints/convertInquiryEndpoint";
export const Inquiries: CollectionConfig = {
slug: "inquiries",
@@ -17,7 +18,36 @@ export const Inquiries: CollectionConfig = {
update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => Boolean(user),
},
endpoints: [
{
path: "/:id/convert-to-lead",
method: "post",
handler: convertInquiryEndpoint,
},
],
fields: [
{
name: "convertButton",
type: "ui",
admin: {
components: {
Field:
"@/src/payload/components/ConvertInquiryButton#ConvertInquiryButton",
},
condition: (data) => {
return !data?.processed;
},
},
},
{
name: "processed",
type: "checkbox",
defaultValue: false,
admin: {
description: "Has this inquiry been converted into a CRM Lead?",
readOnly: true,
},
},
{
name: "name",
type: "text",

View File

@@ -0,0 +1,192 @@
import type { CollectionConfig } from "payload";
export const Projects: CollectionConfig = {
slug: "projects",
labels: {
singular: "Project",
plural: "Projects",
},
admin: {
useAsTitle: "title",
defaultColumns: ["title", "account", "status", "startDate", "targetDate"],
group: "Project Management",
description: "Manage high-level projects for your clients.",
components: {
beforeListTable: ["@/src/payload/views/GanttChart#GanttChartView"],
},
},
access: {
read: ({ req: { user } }) => Boolean(user),
create: ({ req: { user } }) => Boolean(user),
update: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => Boolean(user),
},
fields: [
{
name: "title",
type: "text",
required: true,
label: "Project Title",
},
{
name: "account",
type: "relationship",
relationTo: "crm-accounts",
required: true,
label: "Client / Account",
admin: {
description: "Which account is this project for?",
},
},
{
name: "contact",
type: "relationship",
relationTo: "crm-contacts",
hasMany: true,
label: "Project Stakeholders",
admin: {
description:
"Key contacts from the client side involved in this project.",
},
},
{
name: "status",
type: "select",
options: [
{ label: "Draft", value: "draft" },
{ label: "In Progress", value: "in_progress" },
{ label: "Review", value: "review" },
{ label: "Completed", value: "completed" },
],
defaultValue: "draft",
required: true,
},
{
type: "row",
fields: [
{
name: "startDate",
type: "date",
label: "Start Date",
admin: { width: "25%" },
},
{
name: "targetDate",
type: "date",
label: "Target Date",
admin: { width: "25%" },
},
{
name: "valueMin",
type: "number",
label: "Value From (€)",
admin: {
width: "25%",
placeholder: "z.B. 5000",
},
},
{
name: "valueMax",
type: "number",
label: "Value To (€)",
admin: {
width: "25%",
placeholder: "z.B. 8000",
},
},
],
},
{
name: "briefing",
type: "richText",
label: "Briefing",
admin: {
description: "Project briefing, requirements, or notes.",
},
},
{
name: "attachments",
type: "upload",
relationTo: "media",
hasMany: true,
label: "Attachments",
admin: {
description:
"Upload files, documents, or assets related to this project.",
},
},
{
name: "milestones",
type: "array",
label: "Milestones",
admin: {
description: "Granular deliverables or milestones within this project.",
},
fields: [
{
name: "name",
type: "text",
required: true,
label: "Milestone Name",
admin: {
placeholder: "e.g. Authentication System",
},
},
{
type: "row",
fields: [
{
name: "status",
type: "select",
options: [
{ label: "To Do", value: "todo" },
{ label: "In Progress", value: "in_progress" },
{ label: "Done", value: "done" },
],
defaultValue: "todo",
required: true,
admin: { width: "50%" },
},
{
name: "priority",
type: "select",
options: [
{ label: "Low", value: "low" },
{ label: "Medium", value: "medium" },
{ label: "High", value: "high" },
],
defaultValue: "medium",
admin: { width: "50%" },
},
],
},
{
type: "row",
fields: [
{
name: "startDate",
type: "date",
label: "Start Date",
admin: { width: "50%" },
},
{
name: "targetDate",
type: "date",
label: "Target Date",
admin: { width: "50%" },
},
],
},
{
name: "assignee",
type: "relationship",
relationTo: "users",
label: "Assignee",
admin: {
description: "Internal team member responsible for this milestone.",
},
},
],
},
],
};

View File

@@ -41,9 +41,11 @@ export const AiAnalyzeButton: React.FC = () => {
throw new Error(result.error || "Analysis failed");
}
toast.success(result.message || "AI analysis completed successfully!");
// Refresh the page to show the new media items in the relationship field
router.refresh();
toast.success(
result.message ||
"Analysis started in background. The page will update when finished.",
);
// Removed router.refresh() here because the background task takes ~60s
} catch (error) {
console.error("Analysis error:", error);
toast.error(
@@ -59,23 +61,41 @@ export const AiAnalyzeButton: React.FC = () => {
if (!id) return null; // Only show on existing documents, not when creating new
return (
<div style={{ marginBottom: "1rem", marginTop: "1rem" }}>
<div style={{ marginBottom: "2rem", marginTop: "1rem" }}>
<button
onClick={handleAnalyze}
disabled={isAnalyzing || !hasWebsite}
className="btn btn--style-primary btn--icon-style-none btn--size-medium"
type="button"
style={{
background: "var(--theme-elevation-150)",
border: "1px solid var(--theme-elevation-200)",
color: "var(--theme-text)",
padding: "8px 16px",
borderRadius: "4px",
fontSize: "14px",
cursor: isAnalyzing || !hasWebsite ? "not-allowed" : "pointer",
display: "inline-flex",
alignItems: "center",
gap: "8px",
opacity: isAnalyzing || !hasWebsite ? 0.6 : 1,
fontWeight: "500",
}}
>
{isAnalyzing ? "Analyzing Website..." : "Trigger AI Website Analysis"}
{isAnalyzing ? "✨ AI analysiert..." : " AI Website Analyse starten"}
</button>
<p
style={{
fontSize: "0.8rem",
color: "var(--theme-elevation-400)",
marginTop: "0.5rem",
fontSize: "0.85rem",
color: "var(--theme-elevation-600)",
marginTop: "0.75rem",
maxWidth: "400px",
lineHeight: "1.4",
}}
>
Requires a valid website URL saved on this account.
<strong>Note:</strong> This will crawl the website, generate a strategy
concept, and create a budget estimation. The resulting PDFs will be
attached to the "AI Reports" field below.
</p>
</div>
);

View File

@@ -0,0 +1,88 @@
"use client";
import React, { useState } from "react";
import { useDocumentInfo } from "@payloadcms/ui";
import { toast } from "@payloadcms/ui";
import { useRouter } from "next/navigation";
export const ConvertInquiryButton: React.FC = () => {
const { id } = useDocumentInfo();
const router = useRouter();
const [isConverting, setIsConverting] = useState(false);
const handleConvert = async (e: React.MouseEvent) => {
e.preventDefault();
if (!id) return;
setIsConverting(true);
try {
const response = await fetch(`/api/inquiries/${id}/convert-to-lead`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || "Conversion failed");
}
toast.success(result.message || "Successfully converted to Lead!");
// Redirect to the new account
router.push(`/admin/collections/crm-accounts/${result.accountId}`);
} catch (error) {
console.error("Conversion error:", error);
toast.error(
error instanceof Error
? error.message
: "An error occurred during conversion",
);
} finally {
setIsConverting(false);
}
};
if (!id) return null; // Only show on existing documents
return (
<div style={{ marginBottom: "2rem", marginTop: "1rem" }}>
<button
onClick={handleConvert}
disabled={isConverting}
className="btn btn--style-primary btn--icon-style-none btn--size-medium"
type="button"
style={{
background: "var(--theme-elevation-150)",
border: "1px solid var(--theme-elevation-200)",
color: "var(--theme-text)",
padding: "8px 16px",
borderRadius: "4px",
fontSize: "14px",
cursor: isConverting ? "not-allowed" : "pointer",
display: "inline-flex",
alignItems: "center",
gap: "8px",
opacity: isConverting ? 0.6 : 1,
fontWeight: "500",
}}
>
{isConverting ? "🔄 Konvertiere..." : "🎯 Lead in CRM anlegen"}
</button>
<p
style={{
fontSize: "0.85rem",
color: "var(--theme-elevation-600)",
marginTop: "0.75rem",
maxWidth: "400px",
lineHeight: "1.4",
}}
>
<strong>Info:</strong> Creates a new CRM Account, Contact, and logs the
inquiry message in the CRM Journal.
</p>
</div>
);
};

View File

@@ -31,134 +31,193 @@ export const aiEndpointHandler: PayloadHandler = async (
}
const targetUrl = account.website;
// 2. Setup pipelines and temp dir
const OPENROUTER_KEY =
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!OPENROUTER_KEY) {
return Response.json(
{ error: "OPENROUTER_API_KEY not configured" },
{ status: 500 },
);
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crm-analysis-"));
const monorepoRoot = path.resolve(process.cwd(), "../../");
const crawlDir = path.join(
path.resolve(monorepoRoot, "../at-mintel"),
"data/crawls",
// 2. Immediate Response
const response = Response.json(
{
message:
"Analysis started in background. This will take ~60 seconds. You can safely close or navigate away from this page.",
},
{ status: 202 },
);
const conceptPipeline = new ConceptPipeline({
openrouterKey: OPENROUTER_KEY,
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: tempDir,
crawlDir,
});
// 3. Fire and Forget Background Task
const runBackgroundAnalysis = async () => {
let tempDir = "";
try {
const OPENROUTER_KEY =
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!OPENROUTER_KEY) {
console.error(
"AI Analysis Failed: OPENROUTER_API_KEY not configured",
);
return;
}
const engine = new PdfEngine();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crm-analysis-"));
const monorepoRoot = path.resolve(process.cwd(), "../../");
const crawlDir = path.join(
path.resolve(monorepoRoot, "../at-mintel"),
"data/crawls",
);
// 3. Run Concept Pipeline
// As briefing, we just pass the URL since we don't have deeper text yet.
// The engine's fallback handles URL-only briefings.
const conceptResult = await conceptPipeline.run({
briefing: targetUrl,
url: targetUrl,
comments: "Generated from CRM",
clearCache: false,
});
const conceptPipeline = new ConceptPipeline({
openrouterKey: OPENROUTER_KEY,
zyteApiKey: process.env.ZYTE_API_KEY,
outputDir: tempDir,
crawlDir,
});
const companyName =
conceptResult.auditedFacts?.companyName || account.name || "Company";
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const engine = new PdfEngine();
const conceptPdfPath = path.join(tempDir, `${companyName}_Konzept.pdf`);
// Let's look at how ai-estimate.ts used it: await engine.generateConceptPdf(conceptResult, conceptPdfPath)
// Wait, lint said Property 'generateConceptPdf' does not exist on type 'PdfEngine'.
// Let's re-check `scripts/ai-estimate.ts` lines 106-110.
// It says `await engine.generateConceptPdf(conceptResult, conceptPdfPath);` Wait, how?
// Let's cast to any for now to bypass tsc if there is a version mismatch or internal typings issue
await (engine as any).generateConceptPdf(conceptResult, conceptPdfPath);
console.log(
`[AI Analysis] Starting concept pipeline for ${targetUrl}...`,
);
const conceptResult = await conceptPipeline.run({
briefing: targetUrl,
url: targetUrl,
comments: "Generated from CRM Analysis endpoint",
clearCache: false,
});
// 4. Run Estimation Pipeline
const estimationPipeline = new EstimationPipeline({
openrouterKey: OPENROUTER_KEY,
outputDir: tempDir,
crawlDir: "", // not needed here
});
const companyName =
conceptResult.auditedFacts?.companyName || account.name || "Company";
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const mediaIds: number[] = [];
const estimationResult = await estimationPipeline.run({
concept: conceptResult,
budget: "",
});
// Attempt Concept PDF
const conceptPdfPath = path.join(tempDir, `${companyName}_Konzept.pdf`);
let conceptPdfSuccess = false;
try {
await (engine as any).generateConceptPdf(
conceptResult,
conceptPdfPath,
);
const conceptPdfBuffer = await fs.readFile(conceptPdfPath);
const conceptMedia = await payload.create({
collection: "media",
data: { alt: `Concept PDF for ${companyName}` },
file: {
data: conceptPdfBuffer,
mimetype: "application/pdf",
name: `${companyName}_Konzept_${timestamp}.pdf`,
size: conceptPdfBuffer.byteLength,
},
});
mediaIds.push(Number(conceptMedia.id));
conceptPdfSuccess = true;
console.log(
`[AI Analysis] Concept PDF generated and saved (Media ID: ${conceptMedia.id})`,
);
} catch (pdfErr) {
console.error(
`[AI Analysis] Failed to generate Concept PDF:`,
pdfErr,
);
}
let estimationPdfPath: string | null = null;
if (estimationResult.formState) {
estimationPdfPath = path.join(tempDir, `${companyName}_Angebot.pdf`);
await engine.generateEstimatePdf(
estimationResult.formState,
estimationPdfPath,
);
}
// If Concept PDF failed, save the raw JSON as a text file so data isn't lost
if (!conceptPdfSuccess) {
const jsonPath = path.join(
tempDir,
`${companyName}_Concept_Raw.json`,
);
await fs.writeFile(jsonPath, JSON.stringify(conceptResult, null, 2));
const jsonBuffer = await fs.readFile(jsonPath);
const jsonMedia = await payload.create({
collection: "media",
data: { alt: `Raw Concept JSON for ${companyName}` },
file: {
data: jsonBuffer,
mimetype: "application/json",
name: `${companyName}_Concept_Raw_${timestamp}.json`,
size: jsonBuffer.byteLength,
},
});
mediaIds.push(Number(jsonMedia.id));
console.log(
`[AI Analysis] Saved Raw Concept JSON as fallback (Media ID: ${jsonMedia.id})`,
);
}
// 5. Upload to Payload Media
const mediaIds: number[] = [];
// Run Estimation Pipeline
console.log(
`[AI Analysis] Starting estimation pipeline for ${targetUrl}...`,
);
const estimationPipeline = new EstimationPipeline({
openrouterKey: OPENROUTER_KEY,
outputDir: tempDir,
crawlDir: "", // not needed here
});
// Upload Concept PDF
const conceptPdfBuffer = await fs.readFile(conceptPdfPath);
const conceptMedia = await payload.create({
collection: "media",
data: {
alt: `Concept for ${companyName}`,
},
file: {
data: conceptPdfBuffer,
mimetype: "application/pdf",
name: `${companyName}_Konzept_${timestamp}.pdf`,
size: conceptPdfBuffer.byteLength,
},
});
mediaIds.push(Number(conceptMedia.id));
const estimationResult = await estimationPipeline.run({
concept: conceptResult,
budget: "",
});
// Upload Estimation PDF if generated
if (estimationPdfPath) {
const estPdfBuffer = await fs.readFile(estimationPdfPath);
const estMedia = await payload.create({
collection: "media",
data: {
alt: `Estimation for ${companyName}`,
},
file: {
data: estPdfBuffer,
mimetype: "application/pdf",
name: `${companyName}_Angebot_${timestamp}.pdf`,
size: estPdfBuffer.byteLength,
},
});
mediaIds.push(Number(estMedia.id));
}
if (estimationResult.formState) {
const estimationPdfPath = path.join(
tempDir,
`${companyName}_Angebot.pdf`,
);
try {
await engine.generateEstimatePdf(
estimationResult.formState,
estimationPdfPath,
);
const estPdfBuffer = await fs.readFile(estimationPdfPath);
const estMedia = await payload.create({
collection: "media",
data: { alt: `Estimation PDF for ${companyName}` },
file: {
data: estPdfBuffer,
mimetype: "application/pdf",
name: `${companyName}_Angebot_${timestamp}.pdf`,
size: estPdfBuffer.byteLength,
},
});
mediaIds.push(Number(estMedia.id));
console.log(
`[AI Analysis] Estimation PDF generated and saved (Media ID: ${estMedia.id})`,
);
} catch (estPdfErr) {
console.error(
`[AI Analysis] Failed to generate Estimation PDF:`,
estPdfErr,
);
}
}
// 6. Update Account with new reports
const existingReports = (account.reports || []).map((r: any) =>
typeof r === "number" ? r : Number(r.id || r),
);
// Update Account with new reports
const existingReports = (account.reports || []).map((r: any) =>
typeof r === "number" ? r : Number(r.id || r),
);
await payload.update({
collection: "crm-accounts",
id: String(id),
data: {
reports: [...existingReports, ...mediaIds],
},
});
await payload.update({
collection: "crm-accounts",
id: String(id),
data: {
reports: [...existingReports, ...mediaIds],
},
});
console.log(
`[AI Analysis] Successfully attached ${mediaIds.length} media items to Account ${id}`,
);
} catch (bgError) {
console.error("[AI Analysis] Fatal Background Flow Error:", bgError);
} finally {
if (tempDir) {
fs.rm(tempDir, { recursive: true, force: true }).catch(console.error);
}
}
};
// Cleanup temp dir asynchronously
fs.rm(tempDir, { recursive: true, force: true }).catch(console.error);
// Start background task
runBackgroundAnalysis();
return Response.json({
message: `Successfully analyzed and attached ${mediaIds.length} documents.`,
conceptResult,
});
return response;
} catch (error) {
console.error("AI Endpoint Error:", error);
console.error("AI Endpoint Initial Error:", error);
return Response.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 },

View File

@@ -0,0 +1,89 @@
import type { PayloadRequest, PayloadHandler } from "payload";
export const convertInquiryEndpoint: PayloadHandler = async (
req: PayloadRequest,
) => {
const { id } = req.routeParams;
const payload = req.payload;
try {
const inquiry = await payload.findByID({
collection: "inquiries",
id: String(id),
});
if (!inquiry) {
return Response.json({ error: "Inquiry not found" }, { status: 404 });
}
if ((inquiry as any).processed) {
return Response.json(
{ error: "Inquiry is already processed" },
{ status: 400 },
);
}
// 1. Create CrmAccount
const companyName = inquiry.companyName || inquiry.name;
const account = await payload.create({
collection: "crm-accounts",
data: {
name: companyName,
status: "lead",
leadTemperature: "warm", // Warm because they reached out
},
});
// 2. Create CrmContact
const contact = await payload.create({
collection: "crm-contacts",
data: {
firstName: inquiry.name.split(" ")[0] || inquiry.name,
lastName: inquiry.name.split(" ").slice(1).join(" ") || "",
email: inquiry.email,
account: account.id,
},
});
// 3. Create CrmInteraction (Journal)
let journalSummary = `User submitted an inquiry.\n\nProject Type: ${inquiry.projectType || "N/A"}`;
if (inquiry.message) {
journalSummary += `\n\nMessage:\n${inquiry.message}`;
}
if (inquiry.config) {
journalSummary += `\n\nConfigData:\n${JSON.stringify(inquiry.config, null, 2)}`;
}
await payload.create({
collection: "crm-interactions",
data: {
subject: `Website Anfrage: ${inquiry.projectType || "Allgemein"}`,
type: "email", // Use "type" field underneath ("channel" label)
date: new Date().toISOString(),
summary: journalSummary,
account: account.id,
contact: contact.id,
} as any,
});
// 4. Mark Inquiry as processed
await payload.update({
collection: "inquiries",
id: String(id),
data: {
processed: true,
} as any,
});
return Response.json({
message: "Inquiry successfully converted to CRM Lead.",
accountId: account.id,
});
} catch (error) {
console.error("Convert inquiry error:", error);
return Response.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 },
);
}
};

View File

@@ -1,86 +0,0 @@
import type { CollectionAfterChangeHook } from "payload";
import type { CrmInteraction, CrmContact } from "../../../payload-types";
export const sendEmailOnOutboundInteraction: CollectionAfterChangeHook<
CrmInteraction
> = async ({ doc, operation, req }) => {
// Only fire on initial creation
if (operation !== "create") {
return doc;
}
// Only fire if it's an outbound email
if (doc.type !== "email" || doc.direction !== "outbound") {
return doc;
}
// Ensure there's a subject and content
if (!doc.subject || !doc.content) {
return doc;
}
try {
const payload = req.payload;
let targetEmail = "";
let targetName = "";
// The contact relationship might be populated or just an ID
if (doc.contact) {
if (typeof doc.contact === "object" && "email" in doc.contact) {
targetEmail = doc.contact.email as string;
targetName = `${doc.contact.firstName} ${doc.contact.lastName}`;
} else {
// Fetch the populated contact
const contactDoc = (await payload.findByID({
collection: "crm-contacts",
id: String(doc.contact),
})) as unknown as CrmContact;
if (contactDoc && contactDoc.email) {
targetEmail = contactDoc.email;
targetName = `${contactDoc.firstName} ${contactDoc.lastName}`;
}
}
}
if (!targetEmail) {
console.warn(
"Could not find a valid email address for this outbound interaction.",
);
return doc;
}
// Convert richText content to a string. Simplistic extraction for demonstration
// Since we're sending a simple string representation or basic HTML.
// In a full implementation, you'd serialize the Lexical JSON to HTML.
let htmlContent = "";
if (
doc.content &&
typeof doc.content === "object" &&
"root" in doc.content
) {
// Lexical serialization goes here - doing a basic fallback stringify
htmlContent = JSON.stringify(doc.content);
} else {
htmlContent = String(doc.content);
}
await req.payload.sendEmail({
to: targetEmail,
subject: doc.subject,
html: `
<div>
<p>Hi ${targetName || "there"},</p>
<div>${htmlContent}</div>
</div>
`,
});
console.log(`Successfully sent outbound CRM email to ${targetEmail}`);
} catch (error) {
console.error("Failed to send outbound CRM email:", error);
}
return doc;
};

View File

@@ -0,0 +1,214 @@
/* ─── Gantt Widget (embedded in Projects list) ─── */
.gantt-widget {
border: 1px solid var(--theme-elevation-150);
border-radius: 8px;
margin-bottom: 1.5rem;
overflow: hidden;
background: var(--theme-elevation-50);
}
.gantt-widget__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
user-select: none;
background: var(--theme-elevation-100);
border-bottom: 1px solid var(--theme-elevation-150);
}
.gantt-widget__header:hover {
background: var(--theme-elevation-150);
}
.gantt-widget__title {
font-weight: 600;
font-size: 0.875rem;
display: flex;
align-items: center;
gap: 10px;
}
.gantt-widget__count {
font-weight: 400;
font-size: 0.75rem;
color: var(--theme-elevation-500);
background: var(--theme-elevation-200);
padding: 2px 8px;
border-radius: 99px;
}
.gantt-widget__toggle {
font-size: 1rem;
color: var(--theme-elevation-400);
}
.gantt-widget__body {
padding: 16px;
position: relative;
}
.gantt-widget__empty {
margin: 0;
font-size: 0.85rem;
color: var(--theme-elevation-400);
text-align: center;
padding: 1rem 0;
}
/* ─── Timeline header (months) ─── */
.gantt-timeline__header {
position: relative;
height: 24px;
margin-bottom: 8px;
border-bottom: 1px solid var(--theme-elevation-150);
}
.gantt-timeline__month {
position: absolute;
top: 0;
font-size: 0.7rem;
font-weight: 600;
color: var(--theme-elevation-400);
text-transform: uppercase;
transform: translateX(-50%);
white-space: nowrap;
}
/* ─── Today line ─── */
.gantt-timeline__today {
position: absolute;
top: 40px;
bottom: 16px;
width: 2px;
background: var(--theme-error-500);
z-index: 2;
pointer-events: none;
}
.gantt-timeline__today-label {
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 0.65rem;
font-weight: 700;
color: var(--theme-error-500);
text-transform: uppercase;
white-space: nowrap;
}
/* ─── Rows ─── */
.gantt-timeline__rows {
display: flex;
flex-direction: column;
}
.gantt-row {
display: flex;
align-items: center;
height: 32px;
border-bottom: 1px solid var(--theme-elevation-100);
}
.gantt-row:last-child {
border-bottom: none;
}
.gantt-row--project {
font-weight: 600;
font-size: 0.85rem;
}
.gantt-row--milestone {
font-size: 0.8rem;
color: var(--theme-elevation-600);
}
/* ─── Labels ─── */
.gantt-row__label {
min-width: 180px;
max-width: 220px;
padding-right: 12px;
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.gantt-row__label--indent {
padding-left: 20px;
font-weight: 400;
}
.gantt-row__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.gantt-row__priority {
font-size: 0.6rem;
flex-shrink: 0;
}
.gantt-row__link {
color: inherit;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
}
.gantt-row__link:hover {
text-decoration: underline;
}
/* ─── Bars ─── */
.gantt-row__bar-area {
flex: 1;
position: relative;
height: 100%;
}
.gantt-bar {
position: absolute;
top: 50%;
transform: translateY(-50%);
border-radius: 4px;
height: 16px;
min-width: 4px;
}
.gantt-bar--project {
height: 20px;
opacity: 0.85;
}
.gantt-bar--milestone {
height: 12px;
border-radius: 3px;
}
.gantt-bar__label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.6rem;
font-weight: 600;
color: white;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
pointer-events: none;
}

View File

@@ -0,0 +1,255 @@
"use client";
import React, { useEffect, useState } from "react";
import "./GanttChart.css";
type Milestone = {
id: string;
name: string;
status: "todo" | "in_progress" | "done";
priority: "low" | "medium" | "high";
startDate?: string;
targetDate?: string;
};
type Project = {
id: string;
title: string;
status: "draft" | "in_progress" | "review" | "completed";
startDate?: string;
targetDate?: string;
milestones?: Milestone[];
};
const STATUS_COLORS: Record<string, string> = {
draft: "#94a3b8",
in_progress: "#3b82f6",
review: "#f59e0b",
completed: "#22c55e",
todo: "#94a3b8",
done: "#22c55e",
};
const PRIORITY_LABELS: Record<string, string> = {
low: "▽",
medium: "◆",
high: "▲",
};
export const GanttChartView: React.FC = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
const fetchProjects = async () => {
try {
const res = await fetch("/api/projects?limit=100&depth=0");
if (!res.ok) return;
const json = await res.json();
setProjects(json.docs || []);
} finally {
setLoading(false);
}
};
fetchProjects();
}, []);
// Calculate timeline bounds
const allDates: number[] = [];
projects.forEach((p) => {
if (p.startDate) allDates.push(new Date(p.startDate).getTime());
if (p.targetDate) allDates.push(new Date(p.targetDate).getTime());
p.milestones?.forEach((m) => {
if (m.startDate) allDates.push(new Date(m.startDate).getTime());
if (m.targetDate) allDates.push(new Date(m.targetDate).getTime());
});
});
const minDate = allDates.length > 0 ? Math.min(...allDates) : Date.now();
const maxDate =
allDates.length > 0 ? Math.max(...allDates) : Date.now() + 86400000 * 90;
const totalSpan = Math.max(maxDate - minDate, 86400000); // at least 1 day
const getBarStyle = (start?: string, end?: string) => {
if (!start && !end) return null;
const s = start ? new Date(start).getTime() : minDate;
const e = end ? new Date(end).getTime() : maxDate;
const left = ((s - minDate) / totalSpan) * 100;
const width = Math.max(((e - s) / totalSpan) * 100, 1);
return { left: `${left}%`, width: `${width}%` };
};
const formatDate = (d?: string) => {
if (!d) return "";
return new Date(d).toLocaleDateString("de-DE", {
day: "2-digit",
month: "short",
});
};
// Generate month markers
const monthMarkers: { label: string; left: number }[] = [];
if (allDates.length > 0) {
const startMonth = new Date(minDate);
startMonth.setDate(1);
const endMonth = new Date(maxDate);
const cursor = new Date(startMonth);
while (cursor <= endMonth) {
const pos = ((cursor.getTime() - minDate) / totalSpan) * 100;
if (pos >= 0 && pos <= 100) {
monthMarkers.push({
label: cursor.toLocaleDateString("de-DE", {
month: "short",
year: "2-digit",
}),
left: pos,
});
}
cursor.setMonth(cursor.getMonth() + 1);
}
}
const hasDates = allDates.length >= 2;
if (loading) return null;
if (projects.length === 0) return null;
return (
<div className="gantt-widget">
<div
className="gantt-widget__header"
onClick={() => setCollapsed(!collapsed)}
>
<span className="gantt-widget__title">
📊 Timeline
<span className="gantt-widget__count">
{projects.length} Projects
</span>
</span>
<span className="gantt-widget__toggle">{collapsed ? "▸" : "▾"}</span>
</div>
{!collapsed && (
<div className="gantt-widget__body">
{!hasDates ? (
<p className="gantt-widget__empty">
Add start and target dates to your projects to see the timeline.
</p>
) : (
<>
{/* Month markers */}
<div className="gantt-timeline__header">
{monthMarkers.map((m, i) => (
<span
key={i}
className="gantt-timeline__month"
style={{ left: `${m.left}%` }}
>
{m.label}
</span>
))}
</div>
{/* Today marker */}
{(() => {
const todayPos = ((Date.now() - minDate) / totalSpan) * 100;
if (todayPos >= 0 && todayPos <= 100) {
return (
<div
className="gantt-timeline__today"
style={{ left: `${todayPos}%` }}
>
<span className="gantt-timeline__today-label">Today</span>
</div>
);
}
return null;
})()}
{/* Project rows */}
<div className="gantt-timeline__rows">
{projects.map((project) => (
<React.Fragment key={project.id}>
<div className="gantt-row gantt-row--project">
<div className="gantt-row__label">
<span
className="gantt-row__dot"
style={{
backgroundColor: STATUS_COLORS[project.status],
}}
/>
<a
href={`/admin/collections/projects/${project.id}`}
className="gantt-row__link"
>
{project.title}
</a>
</div>
<div className="gantt-row__bar-area">
{(() => {
const style = getBarStyle(
project.startDate,
project.targetDate,
);
if (!style) return null;
return (
<div
className="gantt-bar gantt-bar--project"
style={{
...style,
backgroundColor: STATUS_COLORS[project.status],
}}
>
<span className="gantt-bar__label">
{formatDate(project.startDate)} {" "}
{formatDate(project.targetDate)}
</span>
</div>
);
})()}
</div>
</div>
{/* Milestone rows */}
{project.milestones?.map((m, i) => {
const barStyle = getBarStyle(m.startDate, m.targetDate);
return (
<div
key={m.id || i}
className="gantt-row gantt-row--milestone"
>
<div className="gantt-row__label gantt-row__label--indent">
<span
className="gantt-row__priority"
title={m.priority}
>
{PRIORITY_LABELS[m.priority]}
</span>
{m.name}
</div>
<div className="gantt-row__bar-area">
{barStyle && (
<div
className="gantt-bar gantt-bar--milestone"
style={{
...barStyle,
backgroundColor: STATUS_COLORS[m.status],
opacity: m.status === "done" ? 0.5 : 1,
}}
/>
)}
</div>
</div>
);
})}
</React.Fragment>
))}
</div>
</>
)}
</div>
)}
</div>
);
};