Feature: Fällige Lead-Wiedervorlagen auf der Leads-Seite
naechsterSchrittDatum wurde erfasst, aber nie als faellige Liste geb√ºndelt — warme Leads gingen unter. Neue Query lead.dueFollowUps (aktive Leads mit faelligem/ueberfaelligem naechsten Schritt) + "Faellige Wiedervorlagen"-Block oben auf /leads. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3d9b739d25
commit
23e850287c
2 files changed
+64
-1
No files matched your search
@@ -1,10 +1,11 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { asc, desc, eq, sql } from 'drizzle-orm';
|
import { and, asc, desc, eq, inArray, isNotNull, lte, sql } from 'drizzle-orm';
|
||||||
import { lead, leadContact, leadTouchpoint, deal, area, user, project } from '@cockpit/db';
|
import { lead, leadContact, leadTouchpoint, deal, area, user, project } from '@cockpit/db';
|
||||||
import type { Db } from '@cockpit/db';
|
import type { Db } from '@cockpit/db';
|
||||||
import { LeadSchemas } from '@cockpit/shared';
|
import { LeadSchemas } from '@cockpit/shared';
|
||||||
import { router, protectedProcedure } from '../trpc.js';
|
import { router, protectedProcedure } from '../trpc.js';
|
||||||
import { parseCsv, normalizeHeader } from '../lib/csv.js';
|
import { parseCsv, normalizeHeader } from '../lib/csv.js';
|
||||||
|
import { todayIso } from '../lib/werktag.js';
|
||||||
|
|
||||||
const SALES_AREA_SLUG = 'vertrieb';
|
const SALES_AREA_SLUG = 'vertrieb';
|
||||||
|
|
||||||
@@ -30,6 +31,35 @@ export const leadRouter = router({
|
|||||||
return rows.map((r) => ({ ...r.lead, ownerName: r.ownerName, projectName: r.projectName }));
|
return rows.map((r) => ({ ...r.lead, ownerName: r.ownerName, projectName: r.projectName }));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fällige Wiedervorlagen: aktive Leads (cold/warm/hot), deren geplanter nächster
|
||||||
|
* Schritt heute oder überfällig ist. naechsterSchrittDatum wurde erfasst, aber
|
||||||
|
* nie als Liste gebündelt — so geht keine warme Spur mehr unter.
|
||||||
|
*/
|
||||||
|
dueFollowUps: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const today = todayIso();
|
||||||
|
const rows = await ctx.db
|
||||||
|
.select({
|
||||||
|
id: lead.id,
|
||||||
|
firma: lead.firma,
|
||||||
|
status: lead.status,
|
||||||
|
ownerName: user.name,
|
||||||
|
naechsterSchritt: lead.naechsterSchritt,
|
||||||
|
faelligAm: lead.naechsterSchrittDatum,
|
||||||
|
})
|
||||||
|
.from(lead)
|
||||||
|
.leftJoin(user, eq(user.id, lead.ownerUserId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(lead.status, ['cold', 'warm', 'hot']),
|
||||||
|
isNotNull(lead.naechsterSchrittDatum),
|
||||||
|
lte(lead.naechsterSchrittDatum, today),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(lead.naechsterSchrittDatum));
|
||||||
|
return rows;
|
||||||
|
}),
|
||||||
|
|
||||||
// Leads, die einem Projekt zugeordnet sind (Vertriebs-Sicht im Projekt).
|
// Leads, die einem Projekt zugeordnet sind (Vertriebs-Sicht im Projekt).
|
||||||
forProject: protectedProcedure
|
forProject: protectedProcedure
|
||||||
.input(LeadSchemas.leadsForProjectInput)
|
.input(LeadSchemas.leadsForProjectInput)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
import { BellRing } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { trpc } from '../lib/trpc';
|
import { trpc } from '../lib/trpc';
|
||||||
@@ -41,6 +42,8 @@ export function LeadsRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<DueFollowUps />
|
||||||
|
|
||||||
{leads.length === 0 ? (
|
{leads.length === 0 ? (
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
@@ -75,6 +78,36 @@ export function LeadsRoute() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DueFollowUps() {
|
||||||
|
const q = trpc.lead.dueFollowUps.useQuery();
|
||||||
|
if (!q.data || q.data.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Body>
|
||||||
|
<h2 className="mb-2 flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<BellRing size={16} className="text-[var(--color-ampel-amber-fg)]" />
|
||||||
|
Fällige Wiedervorlagen ({q.data.length})
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{q.data.map((l) => (
|
||||||
|
<li key={l.id} className="flex items-center justify-between gap-3">
|
||||||
|
<Link to={`/lead/${l.id}`} className="min-w-0 flex-1 truncate hover:underline">
|
||||||
|
<span className="font-medium">{l.firma}</span>
|
||||||
|
{l.naechsterSchritt && (
|
||||||
|
<span className="text-[var(--color-ink-muted)]"> — {l.naechsterSchritt}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<span className="shrink-0 font-mono text-xs text-[var(--color-ampel-amber-fg)]">
|
||||||
|
{l.faelligAm}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card.Body>
|
||||||
|
</Card.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LeadImportDialog({ onClose }: { onClose: () => void }) {
|
function LeadImportDialog({ onClose }: { onClose: () => void }) {
|
||||||
const projectsQ = trpc.project.list.useQuery();
|
const projectsQ = trpc.project.list.useQuery();
|
||||||
const projects = (projectsQ.data ?? []).map((r) => ({ id: r.project.id, name: r.project.name }));
|
const projects = (projectsQ.data ?? []).map((r) => ({ id: r.project.id, name: r.project.name }));
|
||||||
|
|||||||
Reference in new issue
Block a user