feat: implement Project Management with Gantt Chart, Milestones, and CRM enhancements
5
.npmrc
@@ -1,3 +1,2 @@
|
|||||||
@mintel:registry=https://npm.infra.mintel.me/
|
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||||
always-auth=true
|
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864
|
|||||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||||
import { default as default_2ebf44fdf8ebc607cf0de30cff485248 } from "@/src/payload/components/ColorPicker";
|
import { default as default_2ebf44fdf8ebc607cf0de30cff485248 } from "@/src/payload/components/ColorPicker";
|
||||||
import { default as default_a1c6da8fb7dd9846a8b07123ff256d09 } from "@/src/payload/components/IconSelector";
|
import { default as default_a1c6da8fb7dd9846a8b07123ff256d09 } from "@/src/payload/components/IconSelector";
|
||||||
import { AiAnalyzeButton as AiAnalyzeButton_ed488e9819e2cf403a23e3e9cbd3bd17 } from "../../../src/payload/components/AiAnalyzeButton";
|
import { ConvertInquiryButton as ConvertInquiryButton_09fd670bce023a947ab66e4eebea5168 } from "@/src/payload/components/ConvertInquiryButton";
|
||||||
|
import { AiAnalyzeButton as AiAnalyzeButton_51a6009c2b12d068d736ffd2b8182c71 } from "@/src/payload/components/AiAnalyzeButton";
|
||||||
|
import { GanttChartView as GanttChartView_0162b82db971e8f1e27fbdd0aaa2f1f4 } from "@/src/payload/views/GanttChart";
|
||||||
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from "@payloadcms/storage-s3/client";
|
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from "@payloadcms/storage-s3/client";
|
||||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from "@payloadcms/next/rsc";
|
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from "@payloadcms/next/rsc";
|
||||||
|
|
||||||
@@ -96,8 +98,12 @@ export const importMap = {
|
|||||||
default_2ebf44fdf8ebc607cf0de30cff485248,
|
default_2ebf44fdf8ebc607cf0de30cff485248,
|
||||||
"@/src/payload/components/IconSelector#default":
|
"@/src/payload/components/IconSelector#default":
|
||||||
default_a1c6da8fb7dd9846a8b07123ff256d09,
|
default_a1c6da8fb7dd9846a8b07123ff256d09,
|
||||||
"/src/payload/components/AiAnalyzeButton#AiAnalyzeButton":
|
"@/src/payload/components/ConvertInquiryButton#ConvertInquiryButton":
|
||||||
AiAnalyzeButton_ed488e9819e2cf403a23e3e9cbd3bd17,
|
ConvertInquiryButton_09fd670bce023a947ab66e4eebea5168,
|
||||||
|
"@/src/payload/components/AiAnalyzeButton#AiAnalyzeButton":
|
||||||
|
AiAnalyzeButton_51a6009c2b12d068d736ffd2b8182c71,
|
||||||
|
"@/src/payload/views/GanttChart#GanttChartView":
|
||||||
|
GanttChartView_0162b82db971e8f1e27fbdd0aaa2f1f4,
|
||||||
"@payloadcms/storage-s3/client#S3ClientUploadHandler":
|
"@payloadcms/storage-s3/client#S3ClientUploadHandler":
|
||||||
S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
|
S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
|
||||||
"@payloadcms/next/rsc#CollectionCards":
|
"@payloadcms/next/rsc#CollectionCards":
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ const dirname = path.dirname(filename);
|
|||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
serverExternalPackages: ['@mintel/content-engine'],
|
serverExternalPackages: [
|
||||||
|
'@mintel/content-engine',
|
||||||
|
'@mintel/concept-engine',
|
||||||
|
'@mintel/estimation-engine',
|
||||||
|
'@mintel/pdf',
|
||||||
|
'canvas',
|
||||||
|
'sharp',
|
||||||
|
'puppeteer' // Explicitly externalizing heavy node-native dependencies
|
||||||
|
],
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
@@ -37,13 +45,7 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
webpack: (config) => {
|
outputFileTracingRoot: path.join(dirname, '../../'),
|
||||||
config.resolve.alias = {
|
|
||||||
...config.resolve.alias,
|
|
||||||
'@mintel/content-engine': path.resolve(dirname, 'node_modules/@mintel/content-engine'),
|
|
||||||
};
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const withMDX = createMDX({
|
const withMDX = createMDX({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Technical problem solver's blog - practical insights and learning notes",
|
"description": "Technical problem solver's blog - practical insights and learning notes",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm run seed:context && next dev --turbo",
|
"dev": "pnpm run seed:context && next dev --webpack --hostname 0.0.0.0",
|
||||||
"dev:native": "DATABASE_URI=postgres://payload:payload@127.0.0.1:54321/payload PAYLOAD_SECRET=dev-secret pnpm run seed:context && DATABASE_URI=postgres://payload:payload@127.0.0.1:54321/payload PAYLOAD_SECRET=dev-secret next dev --webpack",
|
"dev:native": "DATABASE_URI=postgres://payload:payload@127.0.0.1:54321/payload PAYLOAD_SECRET=dev-secret pnpm run seed:context && DATABASE_URI=postgres://payload:payload@127.0.0.1:54321/payload PAYLOAD_SECRET=dev-secret next dev --webpack",
|
||||||
"seed:context": "tsx ./seed-context.ts",
|
"seed:context": "tsx ./seed-context.ts",
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"@emotion/is-prop-valid": "^1.4.0",
|
"@emotion/is-prop-valid": "^1.4.0",
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
"@mintel/cloner": "^1.8.0",
|
"@mintel/cloner": "^1.9.0",
|
||||||
"@mintel/concept-engine": "link:../../../at-mintel/packages/concept-engine",
|
"@mintel/concept-engine": "link:../../../at-mintel/packages/concept-engine",
|
||||||
"@mintel/content-engine": "link:../../../at-mintel/packages/content-engine",
|
"@mintel/content-engine": "link:../../../at-mintel/packages/content-engine",
|
||||||
"@mintel/estimation-engine": "link:../../../at-mintel/packages/estimation-engine",
|
"@mintel/estimation-engine": "link:../../../at-mintel/packages/estimation-engine",
|
||||||
@@ -99,12 +99,12 @@
|
|||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@eslint/js": "^10.0.0",
|
"@eslint/js": "^10.0.0",
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@mintel/cli": "^1.7.3",
|
"@mintel/cli": "^1.9.0",
|
||||||
"@mintel/eslint-config": "^1.7.3",
|
"@mintel/eslint-config": "^1.9.0",
|
||||||
"@mintel/husky-config": "^1.7.3",
|
"@mintel/husky-config": "^1.9.0",
|
||||||
"@mintel/next-config": "^1.7.3",
|
"@mintel/next-config": "^1.9.0",
|
||||||
"@mintel/next-utils": "^1.7.15",
|
"@mintel/next-utils": "^1.9.0",
|
||||||
"@mintel/tsconfig": "^1.7.3",
|
"@mintel/tsconfig": "^1.9.0",
|
||||||
"@next/eslint-plugin-next": "^16.1.6",
|
"@next/eslint-plugin-next": "^16.1.6",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
|
|||||||
@@ -75,13 +75,28 @@ export interface Config {
|
|||||||
"context-files": ContextFile;
|
"context-files": ContextFile;
|
||||||
"crm-accounts": CrmAccount;
|
"crm-accounts": CrmAccount;
|
||||||
"crm-contacts": CrmContact;
|
"crm-contacts": CrmContact;
|
||||||
|
"crm-topics": CrmTopic;
|
||||||
"crm-interactions": CrmInteraction;
|
"crm-interactions": CrmInteraction;
|
||||||
|
projects: Project;
|
||||||
"payload-kv": PayloadKv;
|
"payload-kv": PayloadKv;
|
||||||
"payload-locked-documents": PayloadLockedDocument;
|
"payload-locked-documents": PayloadLockedDocument;
|
||||||
"payload-preferences": PayloadPreference;
|
"payload-preferences": PayloadPreference;
|
||||||
"payload-migrations": PayloadMigration;
|
"payload-migrations": PayloadMigration;
|
||||||
};
|
};
|
||||||
collectionsJoins: {};
|
collectionsJoins: {
|
||||||
|
"crm-accounts": {
|
||||||
|
topics: "crm-topics";
|
||||||
|
contacts: "crm-contacts";
|
||||||
|
interactions: "crm-interactions";
|
||||||
|
projects: "projects";
|
||||||
|
};
|
||||||
|
"crm-contacts": {
|
||||||
|
interactions: "crm-interactions";
|
||||||
|
};
|
||||||
|
"crm-topics": {
|
||||||
|
interactions: "crm-interactions";
|
||||||
|
};
|
||||||
|
};
|
||||||
collectionsSelect: {
|
collectionsSelect: {
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
media: MediaSelect<false> | MediaSelect<true>;
|
media: MediaSelect<false> | MediaSelect<true>;
|
||||||
@@ -91,9 +106,11 @@ export interface Config {
|
|||||||
"context-files": ContextFilesSelect<false> | ContextFilesSelect<true>;
|
"context-files": ContextFilesSelect<false> | ContextFilesSelect<true>;
|
||||||
"crm-accounts": CrmAccountsSelect<false> | CrmAccountsSelect<true>;
|
"crm-accounts": CrmAccountsSelect<false> | CrmAccountsSelect<true>;
|
||||||
"crm-contacts": CrmContactsSelect<false> | CrmContactsSelect<true>;
|
"crm-contacts": CrmContactsSelect<false> | CrmContactsSelect<true>;
|
||||||
|
"crm-topics": CrmTopicsSelect<false> | CrmTopicsSelect<true>;
|
||||||
"crm-interactions":
|
"crm-interactions":
|
||||||
| CrmInteractionsSelect<false>
|
| CrmInteractionsSelect<false>
|
||||||
| CrmInteractionsSelect<true>;
|
| CrmInteractionsSelect<true>;
|
||||||
|
projects: ProjectsSelect<false> | ProjectsSelect<true>;
|
||||||
"payload-kv": PayloadKvSelect<false> | PayloadKvSelect<true>;
|
"payload-kv": PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||||
"payload-locked-documents":
|
"payload-locked-documents":
|
||||||
| PayloadLockedDocumentsSelect<false>
|
| PayloadLockedDocumentsSelect<false>
|
||||||
@@ -262,6 +279,10 @@ export interface Post {
|
|||||||
*/
|
*/
|
||||||
export interface Inquiry {
|
export interface Inquiry {
|
||||||
id: number;
|
id: number;
|
||||||
|
/**
|
||||||
|
* Has this inquiry been converted into a CRM Lead?
|
||||||
|
*/
|
||||||
|
processed?: boolean | null;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
companyName?: string | null;
|
companyName?: string | null;
|
||||||
@@ -318,57 +339,138 @@ export interface ContextFile {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* Accounts represent companies or organizations. They are the central hub linking Contacts and Interactions together. Use this to track the overall relationship status.
|
||||||
|
*
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "crm-accounts".
|
* via the `definition` "crm-accounts".
|
||||||
*/
|
*/
|
||||||
export interface CrmAccount {
|
export interface CrmAccount {
|
||||||
id: number;
|
id: number;
|
||||||
|
/**
|
||||||
|
* Enter the official name of the business or the research project name.
|
||||||
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
/**
|
/**
|
||||||
* The website of the account, useful for AI analysis.
|
* The main website of the account. Required for triggering the AI Website Analysis.
|
||||||
*/
|
*/
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
/**
|
/**
|
||||||
* Change from Lead to Client upon conversion.
|
* Current lifecycle stage of this business relation.
|
||||||
|
*/
|
||||||
|
status?: ("lead" | "client" | "partner" | "lost") | null;
|
||||||
|
/**
|
||||||
|
* Indicates how likely this lead is to convert soon.
|
||||||
*/
|
*/
|
||||||
status?: ("lead" | "client" | "lost") | null;
|
|
||||||
leadTemperature?: ("cold" | "warm" | "hot") | null;
|
leadTemperature?: ("cold" | "warm" | "hot") | null;
|
||||||
|
/**
|
||||||
|
* The internal team member responsible for this account.
|
||||||
|
*/
|
||||||
assignedTo?: (number | null) | User;
|
assignedTo?: (number | null) | User;
|
||||||
/**
|
/**
|
||||||
* PDFs and strategy documents generated by AI or attached manually.
|
* All generated PDF estimates and strategy documents appear here.
|
||||||
*/
|
*/
|
||||||
reports?: (number | Media)[] | null;
|
reports?: (number | Media)[] | null;
|
||||||
updatedAt: string;
|
/**
|
||||||
createdAt: string;
|
* Projects, deals, or specific topics active for this client.
|
||||||
}
|
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "crm-contacts".
|
|
||||||
*/
|
*/
|
||||||
export interface CrmContact {
|
topics?: {
|
||||||
id: number;
|
docs?: (number | CrmTopic)[];
|
||||||
firstName: string;
|
hasNextPage?: boolean;
|
||||||
lastName: string;
|
totalDocs?: number;
|
||||||
email: string;
|
};
|
||||||
phone?: string | null;
|
/**
|
||||||
linkedIn?: string | null;
|
* All contacts associated with this account.
|
||||||
role?: string | null;
|
*/
|
||||||
account?: (number | null) | CrmAccount;
|
contacts?: {
|
||||||
|
docs?: (number | CrmContact)[];
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
totalDocs?: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Timeline of all communication logged against this account.
|
||||||
|
*/
|
||||||
|
interactions?: {
|
||||||
|
docs?: (number | CrmInteraction)[];
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
totalDocs?: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* All high-level projects associated with this account.
|
||||||
|
*/
|
||||||
|
projects?: {
|
||||||
|
docs?: (number | Project)[];
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
totalDocs?: number;
|
||||||
|
};
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
* Group your interactions (emails, calls, notes) into Topics. This helps you keep track of specific projects with a client.
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "crm-topics".
|
||||||
|
*/
|
||||||
|
export interface CrmTopic {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Which account does this topic belong to?
|
||||||
|
*/
|
||||||
|
account: number | CrmAccount;
|
||||||
|
status: "active" | "paused" | "won" | "lost";
|
||||||
|
/**
|
||||||
|
* Optional: What stage is this deal/project currently in?
|
||||||
|
*/
|
||||||
|
stage?: ("discovery" | "proposal" | "negotiation" | "implementation") | null;
|
||||||
|
/**
|
||||||
|
* Timeline of all emails and notes specifically related to this topic.
|
||||||
|
*/
|
||||||
|
interactions?: {
|
||||||
|
docs?: (number | CrmInteraction)[];
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
totalDocs?: number;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "crm-interactions".
|
* via the `definition` "crm-interactions".
|
||||||
*/
|
*/
|
||||||
export interface CrmInteraction {
|
export interface CrmInteraction {
|
||||||
id: number;
|
id: number;
|
||||||
type: "email" | "call" | "meeting" | "note";
|
/**
|
||||||
|
* Where did this communication take place?
|
||||||
|
*/
|
||||||
|
type:
|
||||||
|
| "email"
|
||||||
|
| "call"
|
||||||
|
| "meeting"
|
||||||
|
| "whatsapp"
|
||||||
|
| "social"
|
||||||
|
| "document"
|
||||||
|
| "note";
|
||||||
direction?: ("inbound" | "outbound") | null;
|
direction?: ("inbound" | "outbound") | null;
|
||||||
|
/**
|
||||||
|
* When did this happen?
|
||||||
|
*/
|
||||||
date: string;
|
date: string;
|
||||||
|
subject: string;
|
||||||
|
/**
|
||||||
|
* Who was involved?
|
||||||
|
*/
|
||||||
contact?: (number | null) | CrmContact;
|
contact?: (number | null) | CrmContact;
|
||||||
account?: (number | null) | CrmAccount;
|
account?: (number | null) | CrmAccount;
|
||||||
subject: string;
|
/**
|
||||||
|
* Optional: Group this entry under a specific project or topic.
|
||||||
|
*/
|
||||||
|
topic?: (number | null) | CrmTopic;
|
||||||
|
/**
|
||||||
|
* Summarize what happened, what was decided, or what the next steps are.
|
||||||
|
*/
|
||||||
content?: {
|
content?: {
|
||||||
root: {
|
root: {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -384,6 +486,110 @@ export interface CrmInteraction {
|
|||||||
};
|
};
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
|
/**
|
||||||
|
* Attach received documents, screenshots, contracts, or any relevant files.
|
||||||
|
*/
|
||||||
|
attachments?: (number | Media)[] | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Contacts are the individual people linked to an Account. A person should only be created once and can be assigned to a company here.
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "crm-contacts".
|
||||||
|
*/
|
||||||
|
export interface CrmContact {
|
||||||
|
id: number;
|
||||||
|
fullName?: string | null;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
/**
|
||||||
|
* Primary email address for communication tracking.
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
phone?: string | null;
|
||||||
|
linkedIn?: string | null;
|
||||||
|
/**
|
||||||
|
* e.g. CEO, Marketing Manager, Technical Lead
|
||||||
|
*/
|
||||||
|
role?: string | null;
|
||||||
|
/**
|
||||||
|
* Link this person to an organization from the Accounts collection.
|
||||||
|
*/
|
||||||
|
account?: (number | null) | CrmAccount;
|
||||||
|
/**
|
||||||
|
* Timeline of all communication logged directly with this person.
|
||||||
|
*/
|
||||||
|
interactions?: {
|
||||||
|
docs?: (number | CrmInteraction)[];
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
totalDocs?: number;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Manage high-level projects for your clients.
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "projects".
|
||||||
|
*/
|
||||||
|
export interface Project {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Which account is this project for?
|
||||||
|
*/
|
||||||
|
account: number | CrmAccount;
|
||||||
|
/**
|
||||||
|
* Key contacts from the client side involved in this project.
|
||||||
|
*/
|
||||||
|
contact?: (number | CrmContact)[] | null;
|
||||||
|
status: "draft" | "in_progress" | "review" | "completed";
|
||||||
|
startDate?: string | null;
|
||||||
|
targetDate?: string | null;
|
||||||
|
valueMin?: number | null;
|
||||||
|
valueMax?: number | null;
|
||||||
|
/**
|
||||||
|
* Project briefing, requirements, or notes.
|
||||||
|
*/
|
||||||
|
briefing?: {
|
||||||
|
root: {
|
||||||
|
type: string;
|
||||||
|
children: {
|
||||||
|
type: any;
|
||||||
|
version: number;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}[];
|
||||||
|
direction: ("ltr" | "rtl") | null;
|
||||||
|
format: "left" | "start" | "center" | "right" | "end" | "justify" | "";
|
||||||
|
indent: number;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
[k: string]: unknown;
|
||||||
|
} | null;
|
||||||
|
/**
|
||||||
|
* Upload files, documents, or assets related to this project.
|
||||||
|
*/
|
||||||
|
attachments?: (number | Media)[] | null;
|
||||||
|
/**
|
||||||
|
* Granular deliverables or milestones within this project.
|
||||||
|
*/
|
||||||
|
milestones?:
|
||||||
|
| {
|
||||||
|
name: string;
|
||||||
|
status: "todo" | "in_progress" | "done";
|
||||||
|
priority?: ("low" | "medium" | "high") | null;
|
||||||
|
startDate?: string | null;
|
||||||
|
targetDate?: string | null;
|
||||||
|
/**
|
||||||
|
* Internal team member responsible for this milestone.
|
||||||
|
*/
|
||||||
|
assignee?: (number | null) | User;
|
||||||
|
id?: string | null;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -443,9 +649,17 @@ export interface PayloadLockedDocument {
|
|||||||
relationTo: "crm-contacts";
|
relationTo: "crm-contacts";
|
||||||
value: number | CrmContact;
|
value: number | CrmContact;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: "crm-topics";
|
||||||
|
value: number | CrmTopic;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: "crm-interactions";
|
relationTo: "crm-interactions";
|
||||||
value: number | CrmInteraction;
|
value: number | CrmInteraction;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: "projects";
|
||||||
|
value: number | Project;
|
||||||
} | null);
|
} | null);
|
||||||
globalSlug?: string | null;
|
globalSlug?: string | null;
|
||||||
user: {
|
user: {
|
||||||
@@ -590,6 +804,7 @@ export interface PostsSelect<T extends boolean = true> {
|
|||||||
* via the `definition` "inquiries_select".
|
* via the `definition` "inquiries_select".
|
||||||
*/
|
*/
|
||||||
export interface InquiriesSelect<T extends boolean = true> {
|
export interface InquiriesSelect<T extends boolean = true> {
|
||||||
|
processed?: T;
|
||||||
name?: T;
|
name?: T;
|
||||||
email?: T;
|
email?: T;
|
||||||
companyName?: T;
|
companyName?: T;
|
||||||
@@ -631,6 +846,10 @@ export interface CrmAccountsSelect<T extends boolean = true> {
|
|||||||
leadTemperature?: T;
|
leadTemperature?: T;
|
||||||
assignedTo?: T;
|
assignedTo?: T;
|
||||||
reports?: T;
|
reports?: T;
|
||||||
|
topics?: T;
|
||||||
|
contacts?: T;
|
||||||
|
interactions?: T;
|
||||||
|
projects?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
@@ -639,6 +858,7 @@ export interface CrmAccountsSelect<T extends boolean = true> {
|
|||||||
* via the `definition` "crm-contacts_select".
|
* via the `definition` "crm-contacts_select".
|
||||||
*/
|
*/
|
||||||
export interface CrmContactsSelect<T extends boolean = true> {
|
export interface CrmContactsSelect<T extends boolean = true> {
|
||||||
|
fullName?: T;
|
||||||
firstName?: T;
|
firstName?: T;
|
||||||
lastName?: T;
|
lastName?: T;
|
||||||
email?: T;
|
email?: T;
|
||||||
@@ -646,6 +866,20 @@ export interface CrmContactsSelect<T extends boolean = true> {
|
|||||||
linkedIn?: T;
|
linkedIn?: T;
|
||||||
role?: T;
|
role?: T;
|
||||||
account?: T;
|
account?: T;
|
||||||
|
interactions?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "crm-topics_select".
|
||||||
|
*/
|
||||||
|
export interface CrmTopicsSelect<T extends boolean = true> {
|
||||||
|
title?: T;
|
||||||
|
account?: T;
|
||||||
|
status?: T;
|
||||||
|
stage?: T;
|
||||||
|
interactions?: T;
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
@@ -657,10 +891,41 @@ export interface CrmInteractionsSelect<T extends boolean = true> {
|
|||||||
type?: T;
|
type?: T;
|
||||||
direction?: T;
|
direction?: T;
|
||||||
date?: T;
|
date?: T;
|
||||||
|
subject?: T;
|
||||||
contact?: T;
|
contact?: T;
|
||||||
account?: T;
|
account?: T;
|
||||||
subject?: T;
|
topic?: T;
|
||||||
content?: T;
|
content?: T;
|
||||||
|
attachments?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "projects_select".
|
||||||
|
*/
|
||||||
|
export interface ProjectsSelect<T extends boolean = true> {
|
||||||
|
title?: T;
|
||||||
|
account?: T;
|
||||||
|
contact?: T;
|
||||||
|
status?: T;
|
||||||
|
startDate?: T;
|
||||||
|
targetDate?: T;
|
||||||
|
valueMin?: T;
|
||||||
|
valueMax?: T;
|
||||||
|
briefing?: T;
|
||||||
|
attachments?: T;
|
||||||
|
milestones?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
name?: T;
|
||||||
|
status?: T;
|
||||||
|
priority?: T;
|
||||||
|
startDate?: T;
|
||||||
|
targetDate?: T;
|
||||||
|
assignee?: T;
|
||||||
|
id?: T;
|
||||||
|
};
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { ContextFiles } from "./src/payload/collections/ContextFiles";
|
|||||||
import { CrmAccounts } from "./src/payload/collections/CrmAccounts";
|
import { CrmAccounts } from "./src/payload/collections/CrmAccounts";
|
||||||
import { CrmContacts } from "./src/payload/collections/CrmContacts";
|
import { CrmContacts } from "./src/payload/collections/CrmContacts";
|
||||||
import { CrmInteractions } from "./src/payload/collections/CrmInteractions";
|
import { CrmInteractions } from "./src/payload/collections/CrmInteractions";
|
||||||
|
import { CrmTopics } from "./src/payload/collections/CrmTopics";
|
||||||
|
import { Projects } from "./src/payload/collections/Projects";
|
||||||
|
|
||||||
import { AiSettings } from "./src/payload/globals/AiSettings";
|
import { AiSettings } from "./src/payload/globals/AiSettings";
|
||||||
|
|
||||||
@@ -42,25 +44,24 @@ export default buildConfig({
|
|||||||
ContextFiles,
|
ContextFiles,
|
||||||
CrmAccounts,
|
CrmAccounts,
|
||||||
CrmContacts,
|
CrmContacts,
|
||||||
|
CrmTopics,
|
||||||
CrmInteractions,
|
CrmInteractions,
|
||||||
|
Projects,
|
||||||
],
|
],
|
||||||
globals: [AiSettings],
|
globals: [AiSettings],
|
||||||
...(process.env.MAIL_HOST || process.env.MAIL_USERNAME
|
|
||||||
? {
|
|
||||||
email: nodemailerAdapter({
|
email: nodemailerAdapter({
|
||||||
defaultFromAddress: process.env.MAIL_FROM || "info@mintel.me",
|
defaultFromAddress: process.env.MAIL_FROM || "info@mintel.me",
|
||||||
defaultFromName: "Mintel.me",
|
defaultFromName: "Mintel.me",
|
||||||
transportOptions: {
|
transportOptions: {
|
||||||
host: process.env.MAIL_HOST || "localhost", // Fallback if missing
|
host: process.env.MAIL_HOST || "localhost",
|
||||||
port: parseInt(process.env.MAIL_PORT || "587"),
|
port: parseInt(process.env.MAIL_PORT || "587", 10),
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.MAIL_USERNAME,
|
user: process.env.MAIL_USERNAME || "user",
|
||||||
pass: process.env.MAIL_PASSWORD,
|
pass: process.env.MAIL_PASSWORD || "pass",
|
||||||
},
|
},
|
||||||
|
...(process.env.MAIL_HOST ? {} : { ignoreTLS: true }),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
editor: lexicalEditor({
|
editor: lexicalEditor({
|
||||||
features: ({ defaultFeatures }) => [
|
features: ({ defaultFeatures }) => [
|
||||||
...defaultFeatures,
|
...defaultFeatures,
|
||||||
@@ -109,10 +110,5 @@ export default buildConfig({
|
|||||||
method: "post",
|
method: "post",
|
||||||
handler: emailWebhookHandler,
|
handler: emailWebhookHandler,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/crm-accounts/:id/analyze",
|
|
||||||
method: "post",
|
|
||||||
handler: aiEndpointHandler,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
|
After Width: | Height: | Size: 5.0 MiB |
BIN
apps/web/public/blog/build-first-digital-architecture.png
Normal file
|
After Width: | Height: | Size: 5.2 MiB |
BIN
apps/web/public/blog/builder-systems-threaten-independence.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
apps/web/public/blog/clean-code-for-business-value.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
apps/web/public/blog/crm-synchronization-headless.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/web/public/blog/digital-longevity-architecture.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
apps/web/public/blog/fixed-price-digital-projects.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
apps/web/public/blog/gdpr-conformity-system-approach.png
Normal file
|
After Width: | Height: | Size: 5.6 MiB |
BIN
apps/web/public/blog/green-it-sustainable-web.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
apps/web/public/blog/hidden-costs-of-wordpress-plugins.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
apps/web/public/blog/no-us-cloud-platforms.png
Normal file
|
After Width: | Height: | Size: 3.8 MiB |
BIN
apps/web/public/blog/professional-hosting-operations.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
apps/web/public/blog/responsive-design-high-fidelity.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
apps/web/public/blog/slow-loading-costs-customers.png
Normal file
|
After Width: | Height: | Size: 5.0 MiB |
BIN
apps/web/public/blog/website-without-cookie-banners.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
apps/web/public/blog/why-agencies-are-slow.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
apps/web/public/blog/why-no-templates-matter.png
Normal file
|
After Width: | Height: | Size: 5.0 MiB |
BIN
apps/web/public/blog/why-websites-break-after-updates.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
21
apps/web/scripts/backup-db.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DB_CONTAINER="mintel-me-postgres-db-1"
|
||||||
|
DB_USER="payload"
|
||||||
|
DB_NAME="payload"
|
||||||
|
|
||||||
|
# Resolve backup dir relative to this script's location
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BACKUP_DIR="${SCRIPT_DIR}/../../../backups"
|
||||||
|
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
|
||||||
|
BACKUP_FILE="${BACKUP_DIR}/payload_backup_${TIMESTAMP}.dump"
|
||||||
|
|
||||||
|
echo "Creating backup directory at ${BACKUP_DIR}..."
|
||||||
|
mkdir -p "${BACKUP_DIR}"
|
||||||
|
|
||||||
|
echo "Dumping database '${DB_NAME}' from container '${DB_CONTAINER}'..."
|
||||||
|
docker exec ${DB_CONTAINER} pg_dump -U ${DB_USER} -F c ${DB_NAME} > "${BACKUP_FILE}"
|
||||||
|
|
||||||
|
echo "✅ Backup successful: ${BACKUP_FILE}"
|
||||||
|
ls -lh "${BACKUP_FILE}"
|
||||||
42
apps/web/scripts/create-user.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { getPayload } from "payload";
|
||||||
|
import configPromise from "../payload.config";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const payload = await getPayload({ config: configPromise });
|
||||||
|
|
||||||
|
const existing = await payload.find({
|
||||||
|
collection: "users",
|
||||||
|
where: { email: { equals: "marc@mintel.me" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing.totalDocs > 0) {
|
||||||
|
console.log("User already exists, updating password...");
|
||||||
|
await payload.update({
|
||||||
|
collection: "users",
|
||||||
|
where: { email: { equals: "marc@mintel.me" } },
|
||||||
|
data: {
|
||||||
|
password: "Tim300493.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("Password updated.");
|
||||||
|
} else {
|
||||||
|
console.log("Creating user...");
|
||||||
|
await payload.create({
|
||||||
|
collection: "users",
|
||||||
|
data: {
|
||||||
|
email: "marc@mintel.me",
|
||||||
|
password: "Tim300493.",
|
||||||
|
name: "Marc Mintel",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("User marc@mintel.me created.");
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create user:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
99
apps/web/scripts/download-thumbnails.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const client = new S3Client({
|
||||||
|
region: process.env.S3_REGION || "fsn1",
|
||||||
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY || "",
|
||||||
|
secretAccessKey: process.env.S3_SECRET_KEY || "",
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function downloadFile(key: string, localPath: string) {
|
||||||
|
try {
|
||||||
|
const bucket = process.env.S3_BUCKET || "mintel";
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
|
const response = await client.send(command);
|
||||||
|
|
||||||
|
if (response.Body) {
|
||||||
|
const dir = path.dirname(localPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = fs.createWriteStream(localPath);
|
||||||
|
const reader = response.Body as any;
|
||||||
|
|
||||||
|
// Node.js stream handling
|
||||||
|
if (typeof reader.pipe === "function") {
|
||||||
|
reader.pipe(stream);
|
||||||
|
} else {
|
||||||
|
// Alternative for web streams if necessary, but in Node it should have pipe
|
||||||
|
const arr = await response.Body.transformToByteArray();
|
||||||
|
fs.writeFileSync(localPath, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
stream.on("finish", resolve);
|
||||||
|
stream.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to download ${key}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMatter(content: string) {
|
||||||
|
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||||
|
if (!match) return { data: {}, content };
|
||||||
|
const data: Record<string, any> = {};
|
||||||
|
match[1].split("\n").forEach((line) => {
|
||||||
|
const [key, ...rest] = line.split(":");
|
||||||
|
if (key && rest.length) {
|
||||||
|
const field = key.trim();
|
||||||
|
let val = rest.join(":").trim();
|
||||||
|
data[field] = val.replace(/^["']|["']$/g, "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { data, content: match[2].trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const webDir = path.resolve(__dirname, "..");
|
||||||
|
const contentDir = path.join(webDir, "content", "blog");
|
||||||
|
const publicDir = path.join(webDir, "public");
|
||||||
|
const prefix = `${process.env.S3_PREFIX || "mintel-me"}/media/`;
|
||||||
|
|
||||||
|
const files = fs.readdirSync(contentDir).filter((f) => f.endsWith(".mdx"));
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const content = fs.readFileSync(path.join(contentDir, file), "utf-8");
|
||||||
|
const { data } = parseMatter(content);
|
||||||
|
|
||||||
|
if (data.thumbnail) {
|
||||||
|
const fileName = path.basename(data.thumbnail);
|
||||||
|
const s3Key = `${prefix}${fileName}`;
|
||||||
|
const localPath = path.join(publicDir, data.thumbnail.replace(/^\//, ""));
|
||||||
|
|
||||||
|
console.log(`Downloading ${s3Key} to ${localPath}...`);
|
||||||
|
await downloadFile(s3Key, localPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Downloads complete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
44
apps/web/scripts/list-s3-media.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const client = new S3Client({
|
||||||
|
region: process.env.S3_REGION || "fsn1",
|
||||||
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY || "",
|
||||||
|
secretAccessKey: process.env.S3_SECRET_KEY || "",
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
const bucket = process.env.S3_BUCKET || "mintel";
|
||||||
|
const prefix = `${process.env.S3_PREFIX || "mintel-me"}/media/`;
|
||||||
|
|
||||||
|
console.log(`Listing objects in bucket: ${bucket}, prefix: ${prefix}`);
|
||||||
|
|
||||||
|
const command = new ListObjectsV2Command({
|
||||||
|
Bucket: bucket,
|
||||||
|
Prefix: prefix,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.send(command);
|
||||||
|
|
||||||
|
if (!response.Contents) {
|
||||||
|
console.log("No objects found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${response.Contents.length} objects:`);
|
||||||
|
response.Contents.forEach((obj) => {
|
||||||
|
console.log(` - ${obj.Key} (${obj.Size} bytes)`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error listing S3 objects:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
@@ -13,20 +13,26 @@ async function run() {
|
|||||||
let retries = 5;
|
let retries = 5;
|
||||||
while (retries > 0) {
|
while (retries > 0) {
|
||||||
try {
|
try {
|
||||||
|
console.log(
|
||||||
|
`Connecting to database (URI: ${process.env.DATABASE_URI || "default"})...`,
|
||||||
|
);
|
||||||
payload = await getPayload({ config: configPromise });
|
payload = await getPayload({ config: configPromise });
|
||||||
break;
|
break;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (
|
if (
|
||||||
e.code === "ECONNREFUSED" ||
|
e.code === "ECONNREFUSED" ||
|
||||||
|
e.code === "ENOTFOUND" ||
|
||||||
e.message?.includes("ECONNREFUSED") ||
|
e.message?.includes("ECONNREFUSED") ||
|
||||||
|
e.message?.includes("ENOTFOUND") ||
|
||||||
e.message?.includes("cannot connect to Postgres")
|
e.message?.includes("cannot connect to Postgres")
|
||||||
) {
|
) {
|
||||||
console.log(
|
console.log(
|
||||||
`Database not ready, retrying in 2 seconds... (${retries} retries left)`,
|
`Database not ready (${e.code || "UNKNOWN"}), retrying in 3 seconds... (${retries} retries left)`,
|
||||||
);
|
);
|
||||||
retries--;
|
retries--;
|
||||||
await new Promise((res) => setTimeout(res, 2000));
|
await new Promise((res) => setTimeout(res, 3000));
|
||||||
} else {
|
} else {
|
||||||
|
console.error("Fatal connection error:", e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CollectionConfig } from "payload";
|
import type { CollectionConfig } from "payload";
|
||||||
|
import { aiEndpointHandler } from "../endpoints/aiEndpoint";
|
||||||
|
|
||||||
export const CrmAccounts: CollectionConfig = {
|
export const CrmAccounts: CollectionConfig = {
|
||||||
slug: "crm-accounts",
|
slug: "crm-accounts",
|
||||||
@@ -10,7 +11,16 @@ export const CrmAccounts: CollectionConfig = {
|
|||||||
useAsTitle: "name",
|
useAsTitle: "name",
|
||||||
defaultColumns: ["name", "status", "leadTemperature", "updatedAt"],
|
defaultColumns: ["name", "status", "leadTemperature", "updatedAt"],
|
||||||
group: "CRM",
|
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: {
|
access: {
|
||||||
read: ({ req: { user } }) => Boolean(user), // Admin only
|
read: ({ req: { user } }) => Boolean(user), // Admin only
|
||||||
create: ({ req: { user } }) => Boolean(user),
|
create: ({ req: { user } }) => Boolean(user),
|
||||||
@@ -23,7 +33,7 @@ export const CrmAccounts: CollectionConfig = {
|
|||||||
type: "ui",
|
type: "ui",
|
||||||
admin: {
|
admin: {
|
||||||
components: {
|
components: {
|
||||||
Field: "/src/payload/components/AiAnalyzeButton#AiAnalyzeButton",
|
Field: "@/src/payload/components/AiAnalyzeButton#AiAnalyzeButton",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -31,14 +41,20 @@ export const CrmAccounts: CollectionConfig = {
|
|||||||
name: "name",
|
name: "name",
|
||||||
type: "text",
|
type: "text",
|
||||||
required: true,
|
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",
|
name: "website",
|
||||||
type: "text",
|
type: "text",
|
||||||
label: "Website URL",
|
label: "Website URL",
|
||||||
admin: {
|
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",
|
name: "status",
|
||||||
type: "select",
|
type: "select",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Lead", value: "lead" },
|
{ label: "Lead (Prospect)", value: "lead" },
|
||||||
{ label: "Client", value: "client" },
|
{ label: "Active Client", value: "client" },
|
||||||
{ label: "Lost", value: "lost" },
|
{ label: "Business Partner", value: "partner" },
|
||||||
|
{ label: "Lost / Archive", value: "lost" },
|
||||||
],
|
],
|
||||||
defaultValue: "lead",
|
defaultValue: "lead",
|
||||||
admin: {
|
admin: {
|
||||||
width: "50%",
|
width: "50%",
|
||||||
description: "Change from Lead to Client upon conversion.",
|
description: "Current lifecycle stage of this business relation.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "leadTemperature",
|
name: "leadTemperature",
|
||||||
type: "select",
|
type: "select",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Cold", value: "cold" },
|
{ label: "❄️ Cold (New Research)", value: "cold" },
|
||||||
{ label: "Warm", value: "warm" },
|
{ label: "🔥 Warm (In Contact)", value: "warm" },
|
||||||
{ label: "Hot", value: "hot" },
|
{ label: "⚡ Hot (Negotiation / Quote)", value: "hot" },
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
condition: (data) => {
|
condition: (data) => {
|
||||||
return data?.status === "lead";
|
return data?.status === "lead";
|
||||||
},
|
},
|
||||||
width: "50%",
|
width: "50%",
|
||||||
|
description: "Indicates how likely this lead is to convert soon.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -79,7 +97,10 @@ export const CrmAccounts: CollectionConfig = {
|
|||||||
name: "assignedTo",
|
name: "assignedTo",
|
||||||
type: "relationship",
|
type: "relationship",
|
||||||
relationTo: "users",
|
relationTo: "users",
|
||||||
label: "Assigned To (User)",
|
label: "Account Manager (User)",
|
||||||
|
admin: {
|
||||||
|
description: "The internal team member responsible for this account.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "reports",
|
name: "reports",
|
||||||
@@ -89,7 +110,45 @@ export const CrmAccounts: CollectionConfig = {
|
|||||||
label: "AI Reports & Documents",
|
label: "AI Reports & Documents",
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
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.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ export const CrmContacts: CollectionConfig = {
|
|||||||
plural: "Contacts",
|
plural: "Contacts",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: "email", // Fallback, will define an afterRead hook or virtual field for a better title
|
useAsTitle: "fullName",
|
||||||
defaultColumns: ["firstName", "lastName", "email", "account"],
|
defaultColumns: ["fullName", "email", "account"],
|
||||||
group: "CRM",
|
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: {
|
access: {
|
||||||
read: ({ req: { user } }) => Boolean(user),
|
read: ({ req: { user } }) => Boolean(user),
|
||||||
@@ -17,7 +19,36 @@ export const CrmContacts: CollectionConfig = {
|
|||||||
update: ({ req: { user } }) => Boolean(user),
|
update: ({ req: { user } }) => Boolean(user),
|
||||||
delete: ({ 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: [
|
fields: [
|
||||||
|
{
|
||||||
|
name: "fullName",
|
||||||
|
type: "text",
|
||||||
|
admin: {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "row",
|
type: "row",
|
||||||
fields: [
|
fields: [
|
||||||
@@ -44,6 +75,9 @@ export const CrmContacts: CollectionConfig = {
|
|||||||
type: "email",
|
type: "email",
|
||||||
required: true,
|
required: true,
|
||||||
unique: true,
|
unique: true,
|
||||||
|
admin: {
|
||||||
|
description: "Primary email address for communication tracking.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "row",
|
type: "row",
|
||||||
@@ -60,6 +94,7 @@ export const CrmContacts: CollectionConfig = {
|
|||||||
type: "text",
|
type: "text",
|
||||||
admin: {
|
admin: {
|
||||||
width: "50%",
|
width: "50%",
|
||||||
|
placeholder: "https://linkedin.com/in/...",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -68,12 +103,29 @@ export const CrmContacts: CollectionConfig = {
|
|||||||
name: "role",
|
name: "role",
|
||||||
type: "text",
|
type: "text",
|
||||||
label: "Job Title / Role",
|
label: "Job Title / Role",
|
||||||
|
admin: {
|
||||||
|
description: "e.g. CEO, Marketing Manager, Technical Lead",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "account",
|
name: "account",
|
||||||
type: "relationship",
|
type: "relationship",
|
||||||
relationTo: "crm-accounts",
|
relationTo: "crm-accounts",
|
||||||
label: "Company / Account",
|
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.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import type { CollectionConfig } from "payload";
|
import type { CollectionConfig } from "payload";
|
||||||
import { sendEmailOnOutboundInteraction } from "../hooks/sendEmailOnOutboundInteraction";
|
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||||
|
|
||||||
export const CrmInteractions: CollectionConfig = {
|
export const CrmInteractions: CollectionConfig = {
|
||||||
slug: "crm-interactions",
|
slug: "crm-interactions",
|
||||||
labels: {
|
labels: {
|
||||||
singular: "Interaction",
|
singular: "Journal Entry",
|
||||||
plural: "Interactions",
|
plural: "Journal",
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
useAsTitle: "subject",
|
useAsTitle: "subject",
|
||||||
defaultColumns: ["type", "direction", "subject", "date", "contact"],
|
defaultColumns: ["type", "subject", "date", "contact", "account"],
|
||||||
group: "CRM",
|
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: {
|
access: {
|
||||||
read: ({ req: { user } }) => Boolean(user),
|
read: ({ req: { user } }) => Boolean(user),
|
||||||
@@ -18,9 +20,6 @@ export const CrmInteractions: CollectionConfig = {
|
|||||||
update: ({ req: { user } }) => Boolean(user),
|
update: ({ req: { user } }) => Boolean(user),
|
||||||
delete: ({ req: { user } }) => Boolean(user),
|
delete: ({ req: { user } }) => Boolean(user),
|
||||||
},
|
},
|
||||||
hooks: {
|
|
||||||
afterChange: [sendEmailOnOutboundInteraction],
|
|
||||||
},
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
type: "row",
|
type: "row",
|
||||||
@@ -28,63 +27,117 @@ export const CrmInteractions: CollectionConfig = {
|
|||||||
{
|
{
|
||||||
name: "type",
|
name: "type",
|
||||||
type: "select",
|
type: "select",
|
||||||
|
label: "Channel",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Email", value: "email" },
|
{ label: "📧 Email", value: "email" },
|
||||||
{ label: "Call", value: "call" },
|
{ label: "📞 Phone Call", value: "call" },
|
||||||
{ label: "Meeting", value: "meeting" },
|
{ label: "🤝 Meeting", value: "meeting" },
|
||||||
{ label: "Note", value: "note" },
|
{ label: "📱 WhatsApp", value: "whatsapp" },
|
||||||
|
{ label: "🌐 Social Media", value: "social" },
|
||||||
|
{ label: "📄 Document / File", value: "document" },
|
||||||
|
{ label: "📝 Internal Note", value: "note" },
|
||||||
],
|
],
|
||||||
required: true,
|
required: true,
|
||||||
defaultValue: "email",
|
defaultValue: "note",
|
||||||
admin: {
|
admin: {
|
||||||
width: "50%",
|
width: "50%",
|
||||||
|
description: "Where did this communication take place?",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "direction",
|
name: "direction",
|
||||||
type: "select",
|
type: "select",
|
||||||
options: [
|
options: [
|
||||||
{ label: "Inbound", value: "inbound" },
|
{ label: "📥 Incoming (from Client)", value: "inbound" },
|
||||||
{ label: "Outbound", value: "outbound" },
|
{ label: "📤 Outgoing (to Client)", value: "outbound" },
|
||||||
],
|
],
|
||||||
admin: {
|
admin: {
|
||||||
width: "50%",
|
hidden: true, // Hide from UI to prevent usage, but keep in DB schema to avoid Drizzle prompts
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "date",
|
name: "date",
|
||||||
type: "date",
|
type: "date",
|
||||||
required: true,
|
required: true,
|
||||||
defaultValue: () => new Date().toISOString(),
|
defaultValue: () => new Date().toISOString(),
|
||||||
admin: {
|
admin: {
|
||||||
|
width: "50%",
|
||||||
date: {
|
date: {
|
||||||
pickerAppearance: "dayAndTime",
|
pickerAppearance: "dayAndTime",
|
||||||
},
|
},
|
||||||
|
description: "When did this happen?",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
name: "contact",
|
|
||||||
type: "relationship",
|
|
||||||
relationTo: "crm-contacts",
|
|
||||||
label: "Contact Person",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "account",
|
|
||||||
type: "relationship",
|
|
||||||
relationTo: "crm-accounts",
|
|
||||||
label: "Account / Company",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "subject",
|
name: "subject",
|
||||||
type: "text",
|
type: "text",
|
||||||
required: true,
|
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%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "topic",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "crm-topics",
|
||||||
|
label: "Related Topic",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Optional: Group this entry under a specific project or topic.",
|
||||||
|
condition: (data) => {
|
||||||
|
return Boolean(data?.account);
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "content",
|
name: "content",
|
||||||
type: "richText",
|
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.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
78
apps/web/src/payload/collections/CrmTopics.ts
Normal 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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { CollectionConfig } from "payload";
|
import type { CollectionConfig } from "payload";
|
||||||
|
import { convertInquiryEndpoint } from "../endpoints/convertInquiryEndpoint";
|
||||||
|
|
||||||
export const Inquiries: CollectionConfig = {
|
export const Inquiries: CollectionConfig = {
|
||||||
slug: "inquiries",
|
slug: "inquiries",
|
||||||
@@ -17,7 +18,36 @@ export const Inquiries: CollectionConfig = {
|
|||||||
update: ({ req: { user } }) => Boolean(user),
|
update: ({ req: { user } }) => Boolean(user),
|
||||||
delete: ({ req: { user } }) => Boolean(user),
|
delete: ({ req: { user } }) => Boolean(user),
|
||||||
},
|
},
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
path: "/:id/convert-to-lead",
|
||||||
|
method: "post",
|
||||||
|
handler: convertInquiryEndpoint,
|
||||||
|
},
|
||||||
|
],
|
||||||
fields: [
|
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",
|
name: "name",
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|||||||
192
apps/web/src/payload/collections/Projects.ts
Normal 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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -41,9 +41,11 @@ export const AiAnalyzeButton: React.FC = () => {
|
|||||||
throw new Error(result.error || "Analysis failed");
|
throw new Error(result.error || "Analysis failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(result.message || "AI analysis completed successfully!");
|
toast.success(
|
||||||
// Refresh the page to show the new media items in the relationship field
|
result.message ||
|
||||||
router.refresh();
|
"Analysis started in background. The page will update when finished.",
|
||||||
|
);
|
||||||
|
// Removed router.refresh() here because the background task takes ~60s
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Analysis error:", error);
|
console.error("Analysis error:", error);
|
||||||
toast.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
|
if (!id) return null; // Only show on existing documents, not when creating new
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: "1rem", marginTop: "1rem" }}>
|
<div style={{ marginBottom: "2rem", marginTop: "1rem" }}>
|
||||||
<button
|
<button
|
||||||
onClick={handleAnalyze}
|
onClick={handleAnalyze}
|
||||||
disabled={isAnalyzing || !hasWebsite}
|
disabled={isAnalyzing || !hasWebsite}
|
||||||
className="btn btn--style-primary btn--icon-style-none btn--size-medium"
|
className="btn btn--style-primary btn--icon-style-none btn--size-medium"
|
||||||
type="button"
|
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>
|
</button>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.8rem",
|
fontSize: "0.85rem",
|
||||||
color: "var(--theme-elevation-400)",
|
color: "var(--theme-elevation-600)",
|
||||||
marginTop: "0.5rem",
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
88
apps/web/src/payload/components/ConvertInquiryButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -31,17 +31,30 @@ export const aiEndpointHandler: PayloadHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetUrl = account.website;
|
const targetUrl = account.website;
|
||||||
// 2. Setup pipelines and temp dir
|
|
||||||
|
// 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 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Fire and Forget Background Task
|
||||||
|
const runBackgroundAnalysis = async () => {
|
||||||
|
let tempDir = "";
|
||||||
|
try {
|
||||||
const OPENROUTER_KEY =
|
const OPENROUTER_KEY =
|
||||||
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||||
if (!OPENROUTER_KEY) {
|
if (!OPENROUTER_KEY) {
|
||||||
return Response.json(
|
console.error(
|
||||||
{ error: "OPENROUTER_API_KEY not configured" },
|
"AI Analysis Failed: OPENROUTER_API_KEY not configured",
|
||||||
{ status: 500 },
|
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crm-analysis-"));
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crm-analysis-"));
|
||||||
const monorepoRoot = path.resolve(process.cwd(), "../../");
|
const monorepoRoot = path.resolve(process.cwd(), "../../");
|
||||||
const crawlDir = path.join(
|
const crawlDir = path.join(
|
||||||
path.resolve(monorepoRoot, "../at-mintel"),
|
path.resolve(monorepoRoot, "../at-mintel"),
|
||||||
@@ -57,29 +70,80 @@ export const aiEndpointHandler: PayloadHandler = async (
|
|||||||
|
|
||||||
const engine = new PdfEngine();
|
const engine = new PdfEngine();
|
||||||
|
|
||||||
// 3. Run Concept Pipeline
|
console.log(
|
||||||
// As briefing, we just pass the URL since we don't have deeper text yet.
|
`[AI Analysis] Starting concept pipeline for ${targetUrl}...`,
|
||||||
// The engine's fallback handles URL-only briefings.
|
);
|
||||||
const conceptResult = await conceptPipeline.run({
|
const conceptResult = await conceptPipeline.run({
|
||||||
briefing: targetUrl,
|
briefing: targetUrl,
|
||||||
url: targetUrl,
|
url: targetUrl,
|
||||||
comments: "Generated from CRM",
|
comments: "Generated from CRM Analysis endpoint",
|
||||||
clearCache: false,
|
clearCache: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const companyName =
|
const companyName =
|
||||||
conceptResult.auditedFacts?.companyName || account.name || "Company";
|
conceptResult.auditedFacts?.companyName || account.name || "Company";
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const mediaIds: number[] = [];
|
||||||
|
|
||||||
|
// Attempt Concept PDF
|
||||||
const conceptPdfPath = path.join(tempDir, `${companyName}_Konzept.pdf`);
|
const conceptPdfPath = path.join(tempDir, `${companyName}_Konzept.pdf`);
|
||||||
// Let's look at how ai-estimate.ts used it: await engine.generateConceptPdf(conceptResult, conceptPdfPath)
|
let conceptPdfSuccess = false;
|
||||||
// Wait, lint said Property 'generateConceptPdf' does not exist on type 'PdfEngine'.
|
try {
|
||||||
// Let's re-check `scripts/ai-estimate.ts` lines 106-110.
|
await (engine as any).generateConceptPdf(
|
||||||
// It says `await engine.generateConceptPdf(conceptResult, conceptPdfPath);` Wait, how?
|
conceptResult,
|
||||||
// Let's cast to any for now to bypass tsc if there is a version mismatch or internal typings issue
|
conceptPdfPath,
|
||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Run Estimation Pipeline
|
// 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})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run Estimation Pipeline
|
||||||
|
console.log(
|
||||||
|
`[AI Analysis] Starting estimation pipeline for ${targetUrl}...`,
|
||||||
|
);
|
||||||
const estimationPipeline = new EstimationPipeline({
|
const estimationPipeline = new EstimationPipeline({
|
||||||
openrouterKey: OPENROUTER_KEY,
|
openrouterKey: OPENROUTER_KEY,
|
||||||
outputDir: tempDir,
|
outputDir: tempDir,
|
||||||
@@ -91,42 +155,20 @@ export const aiEndpointHandler: PayloadHandler = async (
|
|||||||
budget: "",
|
budget: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
let estimationPdfPath: string | null = null;
|
|
||||||
if (estimationResult.formState) {
|
if (estimationResult.formState) {
|
||||||
estimationPdfPath = path.join(tempDir, `${companyName}_Angebot.pdf`);
|
const estimationPdfPath = path.join(
|
||||||
|
tempDir,
|
||||||
|
`${companyName}_Angebot.pdf`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
await engine.generateEstimatePdf(
|
await engine.generateEstimatePdf(
|
||||||
estimationResult.formState,
|
estimationResult.formState,
|
||||||
estimationPdfPath,
|
estimationPdfPath,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Upload to Payload Media
|
|
||||||
const mediaIds: number[] = [];
|
|
||||||
|
|
||||||
// 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));
|
|
||||||
|
|
||||||
// Upload Estimation PDF if generated
|
|
||||||
if (estimationPdfPath) {
|
|
||||||
const estPdfBuffer = await fs.readFile(estimationPdfPath);
|
const estPdfBuffer = await fs.readFile(estimationPdfPath);
|
||||||
const estMedia = await payload.create({
|
const estMedia = await payload.create({
|
||||||
collection: "media",
|
collection: "media",
|
||||||
data: {
|
data: { alt: `Estimation PDF for ${companyName}` },
|
||||||
alt: `Estimation for ${companyName}`,
|
|
||||||
},
|
|
||||||
file: {
|
file: {
|
||||||
data: estPdfBuffer,
|
data: estPdfBuffer,
|
||||||
mimetype: "application/pdf",
|
mimetype: "application/pdf",
|
||||||
@@ -135,9 +177,18 @@ export const aiEndpointHandler: PayloadHandler = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
mediaIds.push(Number(estMedia.id));
|
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
|
// Update Account with new reports
|
||||||
const existingReports = (account.reports || []).map((r: any) =>
|
const existingReports = (account.reports || []).map((r: any) =>
|
||||||
typeof r === "number" ? r : Number(r.id || r),
|
typeof r === "number" ? r : Number(r.id || r),
|
||||||
);
|
);
|
||||||
@@ -149,16 +200,24 @@ export const aiEndpointHandler: PayloadHandler = async (
|
|||||||
reports: [...existingReports, ...mediaIds],
|
reports: [...existingReports, ...mediaIds],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log(
|
||||||
// Cleanup temp dir asynchronously
|
`[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);
|
fs.rm(tempDir, { recursive: true, force: true }).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return Response.json({
|
// Start background task
|
||||||
message: `Successfully analyzed and attached ${mediaIds.length} documents.`,
|
runBackgroundAnalysis();
|
||||||
conceptResult,
|
|
||||||
});
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("AI Endpoint Error:", error);
|
console.error("AI Endpoint Initial Error:", error);
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
89
apps/web/src/payload/endpoints/convertInquiryEndpoint.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
0
apps/web/src/payload/endpoints/projectsEndpoint.ts
Normal 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;
|
|
||||||
};
|
|
||||||
214
apps/web/src/payload/views/GanttChart/GanttChart.css
Normal 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;
|
||||||
|
}
|
||||||
255
apps/web/src/payload/views/GanttChart/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
|
restart: unless-stopped
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
@@ -22,15 +23,17 @@ services:
|
|||||||
- apps_node_modules:/app/apps/web/node_modules
|
- apps_node_modules:/app/apps/web/node_modules
|
||||||
- ../at-mintel:/at-mintel
|
- ../at-mintel:/at-mintel
|
||||||
- pnpm_store:/pnpm # Cache pnpm store
|
- pnpm_store:/pnpm # Cache pnpm store
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- NEXT_TELEMETRY_DISABLED=1
|
- NEXT_TELEMETRY_DISABLED=1
|
||||||
- CI=true
|
# - CI=true
|
||||||
- NPM_TOKEN=${NPM_TOKEN:-}
|
- NPM_TOKEN=${NPM_TOKEN:-}
|
||||||
- DATABASE_URI=postgres://${postgres_DB_USER:-payload}:${postgres_DB_PASSWORD:-payload}@postgres-db:5432/${postgres_DB_NAME:-payload}
|
- DATABASE_URI=postgres://${postgres_DB_USER:-payload}:${postgres_DB_PASSWORD:-payload}@postgres-db:5432/${postgres_DB_NAME:-payload}
|
||||||
- PAYLOAD_SECRET=dev-secret
|
- PAYLOAD_SECRET=dev-secret
|
||||||
command: >
|
command: >
|
||||||
sh -c "pnpm install && pnpm --filter @mintel/web dev"
|
sh -c "pnpm install --no-frozen-lockfile && pnpm --filter @mintel/web dev"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|||||||
15
package.json
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.18.3",
|
"packageManager": "pnpm@10.18.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml up -d postgres-db proxy && NODE_ENV=development pnpm --filter @mintel/web dev:native",
|
"dev": "bash -c 'trap \"COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml up app postgres-db --remove-orphans'",
|
||||||
"dev:docker": "docker network create infra 2>/dev/null || true && echo \"\\n🚀 Dockerized Environment Starting...\\n\\n📱 App: http://mintel.localhost\\n🚦 Caddy Proxy: http://localhost:80\\n\" && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml up app postgres-db",
|
"dev:docker": "docker network create infra 2>/dev/null || true && echo \"\\n🚀 Dockerized Environment Starting...\\n\\n📱 App: http://mintel.localhost\\n🚦 Caddy Proxy: http://localhost:80\\n\" && COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml up app postgres-db",
|
||||||
"dev:clean": "pnpm dev:stop && rm -rf apps/web/.next apps/web/node_modules && pnpm install && pnpm dev",
|
"dev:clean": "pnpm dev:stop && rm -rf apps/web/.next apps/web/node_modules && pnpm install && pnpm dev",
|
||||||
"dev:stop": "COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down",
|
"dev:stop": "COMPOSE_PROJECT_NAME=mintel-me docker-compose -f docker-compose.dev.yml down",
|
||||||
@@ -15,17 +15,18 @@
|
|||||||
"test": "pnpm -r test",
|
"test": "pnpm -r test",
|
||||||
"lint:yaml": "node scripts/lint-yaml.js",
|
"lint:yaml": "node scripts/lint-yaml.js",
|
||||||
"optimize-blog": "tsx --env-file=.env apps/web/scripts/optimize-blog-post.ts",
|
"optimize-blog": "tsx --env-file=.env apps/web/scripts/optimize-blog-post.ts",
|
||||||
|
"db:backup": "bash apps/web/scripts/backup-db.sh",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.3",
|
||||||
"@eslint/js": "^10.0.0",
|
"@eslint/js": "^10.0.0",
|
||||||
"@mintel/cli": "^1.8.21",
|
"@mintel/cli": "^1.9.0",
|
||||||
"@mintel/eslint-config": "^1.8.21",
|
"@mintel/eslint-config": "^1.9.0",
|
||||||
"@mintel/husky-config": "^1.8.21",
|
"@mintel/husky-config": "^1.9.0",
|
||||||
"@mintel/next-config": "^1.8.21",
|
"@mintel/next-config": "^1.9.0",
|
||||||
"@mintel/next-utils": "^1.8.21",
|
"@mintel/next-utils": "^1.9.0",
|
||||||
"@mintel/tsconfig": "^1.8.21",
|
"@mintel/tsconfig": "^1.9.0",
|
||||||
"@next/eslint-plugin-next": "^16.1.6",
|
"@next/eslint-plugin-next": "^16.1.6",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/context-async-hooks": "^2.1.0",
|
"@opentelemetry/context-async-hooks": "^2.1.0",
|
||||||
|
|||||||