Ein Projekt lebt in genau einem Heimat-Bereich und wird einmal angelegt; Leads bekommen eine optionale Verknüpfung dorthin (lead.projectId), egal in welchem Bereich das Projekt wohnt — kein redundantes Anlegen mehr. - Migration 0011: lead.project_id (FK → project, ON DELETE SET NULL). - Lead anlegen / CSV-Import / Lead-Detail: Projekt zuordnen; CSV ordnet alle importierten Leads einem Projekt zu. - Projektseite: neue "Leads"-Sektion (Vertriebs-Sicht des Projekts). - Vertrieb: "Projekte mit Vertriebsaktivität" — bereichsübergreifend alle Projekte mit verknüpften Leads (so erscheint ein Entwicklungsprojekt hier, WEIL es Leads hat). Rein interne Projekte ohne Leads tauchen nicht auf. - Lead-Liste/Detail zeigen das verknüpfte Projekt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
149 lines
5.6 KiB
TypeScript
149 lines
5.6 KiB
TypeScript
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(),
|
|
projectId: 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(),
|
|
projectId: 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>;
|
|
|
|
export const leadsForProjectInput = z.object({ projectId: z.string().uuid() });
|
|
|
|
// 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>;
|
|
|
|
// CSV-Import: rohe CSV-Datei wird serverseitig geparst (header-basiert).
|
|
export const importLeadsCsvInput = z.object({
|
|
csv: z.string().min(1).max(2_000_000),
|
|
projectId: z.string().uuid().optional(), // alle importierten Leads diesem Projekt zuordnen
|
|
});
|
|
export type ImportLeadsCsvInput = z.infer<typeof importLeadsCsvInput>;
|