feat(vertrieb): Lead-CRM mit Dossiers, Kontakten & Austausch-Log
Neues Lead-Modul im Vertriebsbereich: qualitative Dossiers (Stammdaten, Pain & Bedarf, Soft Factors, Strategie), Kontaktpersonen, Austausch-Log mit Follow-up-Daten und Lead→Deal-Promotion. Routen /leads (Liste, nach Status gruppiert) und /lead/:id (Dossier), Teaser im Bereich Vertrieb. Migration 0008_lead_crm (lead, lead_contact, lead_touchpoint). Datenmodell-Tabellen liegen in der gemeinsamen schema.ts (Folge-Commit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8933169ba9
commit
d72aa9fbf3
9 files changed
+4291
No files matched your search
@@ -0,0 +1,86 @@
|
||||
CREATE TABLE "lead" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"area_id" uuid NOT NULL,
|
||||
"firma" text NOT NULL,
|
||||
"status" text DEFAULT 'cold' NOT NULL,
|
||||
"score" integer DEFAULT 0 NOT NULL,
|
||||
"owner_user_id" uuid,
|
||||
"branche" text,
|
||||
"sub_segment" text,
|
||||
"standorte" text,
|
||||
"mitarbeiterzahl" text,
|
||||
"umsatz" text,
|
||||
"website" text,
|
||||
"quelle" text,
|
||||
"pain_point" text,
|
||||
"status_quo_kosten" text,
|
||||
"bestehende_loesung" text,
|
||||
"wechselgrund" text,
|
||||
"persoenlicher_erfolg" text,
|
||||
"budget_indikation" text,
|
||||
"entscheidungshorizont" text,
|
||||
"compelling_event" text,
|
||||
"mitbewerber" text,
|
||||
"buying_center" text,
|
||||
"beschaffungsweg" text,
|
||||
"kommunikationsstil" text,
|
||||
"vorlieben" text,
|
||||
"hot_buttons" text,
|
||||
"no_gos" text,
|
||||
"druckpunkte" text,
|
||||
"anker" text,
|
||||
"vertrauenslevel" integer,
|
||||
"nutzen_hypothese" text,
|
||||
"differenzierer" text,
|
||||
"naechster_schritt" text,
|
||||
"naechster_schritt_datum" date,
|
||||
"risiken" text,
|
||||
"plan_b" text,
|
||||
"offene_fragen" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"anhaenge" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"promoted_deal_id" uuid,
|
||||
"created_by" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "lead_contact" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"lead_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"rolle" text,
|
||||
"funktion" text,
|
||||
"email" text,
|
||||
"telefon" text,
|
||||
"linkedin" text,
|
||||
"im_buying_center_seit" text,
|
||||
"sprache" text,
|
||||
"is_primary" boolean DEFAULT false NOT NULL,
|
||||
"order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "lead_touchpoint" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"lead_id" uuid NOT NULL,
|
||||
"datum" date NOT NULL,
|
||||
"kanal" text NOT NULL,
|
||||
"bearbeiter_user_id" uuid,
|
||||
"inhalt" text NOT NULL,
|
||||
"outcome" text,
|
||||
"follow_up_bis" date,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "lead" ADD CONSTRAINT "lead_area_id_area_id_fk" FOREIGN KEY ("area_id") REFERENCES "public"."area"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead" ADD CONSTRAINT "lead_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead" ADD CONSTRAINT "lead_promoted_deal_id_deal_id_fk" FOREIGN KEY ("promoted_deal_id") REFERENCES "public"."deal"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead" ADD CONSTRAINT "lead_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead_contact" ADD CONSTRAINT "lead_contact_lead_id_lead_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."lead"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead_touchpoint" ADD CONSTRAINT "lead_touchpoint_lead_id_lead_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."lead"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "lead_touchpoint" ADD CONSTRAINT "lead_touchpoint_bearbeiter_user_id_user_id_fk" FOREIGN KEY ("bearbeiter_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_lead_status" ON "lead" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "idx_lead_owner" ON "lead" USING btree ("owner_user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_lead_contact_lead" ON "lead_contact" USING btree ("lead_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_lead_touchpoint_lead" ON "lead_touchpoint" USING btree ("lead_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_lead_touchpoint_followup" ON "lead_touchpoint" USING btree ("follow_up_bis");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,137 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Status & Enums --------------------------------------------------------------
|
||||
export const leadStatusSchema = z.enum(['cold', 'warm', 'hot', 'won', 'lost']);
|
||||
export const LEAD_STATUSES = leadStatusSchema.options;
|
||||
|
||||
export const contactFunktionSchema = z.enum([
|
||||
'entscheider',
|
||||
'influencer',
|
||||
'gatekeeper',
|
||||
'end_user',
|
||||
'champion',
|
||||
]);
|
||||
export const CONTACT_FUNKTIONEN = contactFunktionSchema.options;
|
||||
|
||||
export const touchpointKanalSchema = z.enum([
|
||||
'call',
|
||||
'mail',
|
||||
'linkedin',
|
||||
'termin',
|
||||
'event',
|
||||
'sonstiges',
|
||||
]);
|
||||
export const TOUCHPOINT_KANAELE = touchpointKanalSchema.options;
|
||||
|
||||
// JSONB-Substrukturen ---------------------------------------------------------
|
||||
export const offeneFrageSchema = z.object({
|
||||
text: z.string().min(1).max(500),
|
||||
done: z.boolean(),
|
||||
});
|
||||
export const anhangSchema = z.object({
|
||||
label: z.string().min(1).max(200),
|
||||
url: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
// Lead-Kernfelder (alle optional außer firma). Wird für create + update genutzt.
|
||||
const leadCoreFields = {
|
||||
firma: z.string().min(1).max(200),
|
||||
status: leadStatusSchema,
|
||||
score: z.number().int().min(0).max(10),
|
||||
ownerUserId: z.string().uuid().nullable(),
|
||||
branche: z.string().max(200).nullable(),
|
||||
subSegment: z.string().max(200).nullable(),
|
||||
standorte: z.string().max(500).nullable(),
|
||||
mitarbeiterzahl: z.string().max(100).nullable(),
|
||||
umsatz: z.string().max(100).nullable(),
|
||||
website: z.string().max(500).nullable(),
|
||||
quelle: z.string().max(500).nullable(),
|
||||
painPoint: z.string().max(2000).nullable(),
|
||||
statusQuoKosten: z.string().max(2000).nullable(),
|
||||
bestehendeLoesung: z.string().max(1000).nullable(),
|
||||
wechselgrund: z.string().max(2000).nullable(),
|
||||
persoenlicherErfolg: z.string().max(2000).nullable(),
|
||||
budgetIndikation: z.string().max(500).nullable(),
|
||||
entscheidungshorizont: z.string().max(500).nullable(),
|
||||
compellingEvent: z.string().max(1000).nullable(),
|
||||
mitbewerber: z.string().max(1000).nullable(),
|
||||
buyingCenter: z.string().max(1000).nullable(),
|
||||
beschaffungsweg: z.string().max(500).nullable(),
|
||||
kommunikationsstil: z.string().max(1000).nullable(),
|
||||
vorlieben: z.string().max(1000).nullable(),
|
||||
hotButtons: z.string().max(1000).nullable(),
|
||||
noGos: z.string().max(1000).nullable(),
|
||||
druckpunkte: z.string().max(1000).nullable(),
|
||||
anker: z.string().max(1000).nullable(),
|
||||
vertrauenslevel: z.number().int().min(1).max(5).nullable(),
|
||||
nutzenHypothese: z.string().max(1000).nullable(),
|
||||
differenzierer: z.string().max(1000).nullable(),
|
||||
naechsterSchritt: z.string().max(1000).nullable(),
|
||||
naechsterSchrittDatum: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable(),
|
||||
risiken: z.string().max(1000).nullable(),
|
||||
planB: z.string().max(1000).nullable(),
|
||||
offeneFragen: z.array(offeneFrageSchema).max(100),
|
||||
anhaenge: z.array(anhangSchema).max(50),
|
||||
};
|
||||
|
||||
// Create: nur firma ist Pflicht, Rest optional mit sinnvollen Defaults.
|
||||
export const createLeadInput = z.object({
|
||||
firma: leadCoreFields.firma,
|
||||
status: leadStatusSchema.default('cold'),
|
||||
score: z.number().int().min(0).max(10).default(0),
|
||||
ownerUserId: z.string().uuid().optional(),
|
||||
branche: z.string().max(200).optional(),
|
||||
subSegment: z.string().max(200).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
quelle: z.string().max(500).optional(),
|
||||
});
|
||||
export type CreateLeadInput = z.infer<typeof createLeadInput>;
|
||||
|
||||
// Update: id + beliebige Teilmenge der Kernfelder (partial patch).
|
||||
export const updateLeadInput = z
|
||||
.object({ id: z.string().uuid() })
|
||||
.merge(z.object(leadCoreFields).partial());
|
||||
export type UpdateLeadInput = z.infer<typeof updateLeadInput>;
|
||||
|
||||
export const leadIdInput = z.object({ id: z.string().uuid() });
|
||||
|
||||
// Kontakte --------------------------------------------------------------------
|
||||
export const addContactInput = z.object({
|
||||
leadId: z.string().uuid(),
|
||||
name: z.string().min(1).max(200),
|
||||
rolle: z.string().max(200).optional(),
|
||||
funktion: contactFunktionSchema.optional(),
|
||||
email: z.string().max(320).optional(),
|
||||
telefon: z.string().max(100).optional(),
|
||||
linkedin: z.string().max(500).optional(),
|
||||
imBuyingCenterSeit: z.string().max(100).optional(),
|
||||
sprache: z.string().max(100).optional(),
|
||||
isPrimary: z.boolean().default(false),
|
||||
});
|
||||
export type AddContactInput = z.infer<typeof addContactInput>;
|
||||
|
||||
export const removeContactInput = z.object({ contactId: z.string().uuid() });
|
||||
|
||||
// Touchpoints (Austausch-Log) -------------------------------------------------
|
||||
export const addTouchpointInput = z.object({
|
||||
leadId: z.string().uuid(),
|
||||
datum: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
kanal: touchpointKanalSchema,
|
||||
inhalt: z.string().min(1).max(2000),
|
||||
outcome: z.string().max(1000).optional(),
|
||||
followUpBis: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
});
|
||||
export type AddTouchpointInput = z.infer<typeof addTouchpointInput>;
|
||||
|
||||
export const removeTouchpointInput = z.object({ touchpointId: z.string().uuid() });
|
||||
|
||||
// Promotion → Pipeline-Deal ---------------------------------------------------
|
||||
export const promoteToDealInput = z.object({
|
||||
leadId: z.string().uuid(),
|
||||
valueEur: z.number().int().min(0).default(0),
|
||||
probability: z.number().int().min(0).max(100).default(50),
|
||||
});
|
||||
export type PromoteToDealInput = z.infer<typeof promoteToDealInput>;
|
||||
Reference in new issue
Block a user