项目文件夹

文件
2026-07-13 12:58:18 +08:00

485 行
13 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
export type Stage =
| "Lead"
| "Qualified"
| "Proposal"
| "Negotiation"
| "Closed Won"
| "Closed Lost";
export const STAGES: Stage[] = [
"Lead",
"Qualified",
"Proposal",
"Negotiation",
"Closed Won",
"Closed Lost",
];
export type ProductCategory =
| "Laptop"
| "Workstation"
| "Server"
| "Display"
| "Accessory";
export interface Product {
id: string;
name: string;
category: ProductCategory;
sku: string;
unitPrice: number;
photoUrl: string;
specs: string;
blurb: string;
}
export interface Salesperson {
id: string;
name: string;
email: string;
avatarUrl: string;
role: "AE" | "SDR" | "Manager";
region: string;
quota: number;
}
export interface DealLineItem {
productId: string;
qty: number;
unitPrice: number;
}
export interface ReportMetrics {
bookings: number;
weightedForecast: number;
winRate: number | null;
dealsWon: number;
dealsOpen: number;
byStage: { stage: Stage; count: number; value: number }[];
byCategory: { category: ProductCategory; value: number }[];
leaderboard: {
salespersonId: string;
name: string;
bookings: number;
attainment: number;
}[];
}
export interface Report {
id: string;
title: string;
periodStart: string;
periodEnd: string;
generatedAt: string;
summary: string;
metrics: ReportMetrics;
highlights: string[];
}
export interface QuoteLineItem {
productId: string;
name: string;
category: ProductCategory;
qty: number;
unitPrice: number;
lineTotal: number;
photoUrl: string;
}
export interface Quote {
id: string;
accountId: string;
accountName: string;
useCase?: string;
seats?: number;
lineItems: QuoteLineItem[];
subtotal: number;
note?: string;
status: "approved";
createdAt: string;
}
export interface Account {
id: string;
name: string;
domain: string;
industry?: string;
sizeEmployees?: number;
location?: string;
enrichment?: EnrichmentResult;
}
export interface Contact {
id: string;
accountId: string;
name: string;
title: string;
email: string;
}
export interface Deal {
id: string;
accountId: string;
name: string;
amount: number;
stage: Stage;
probability: number;
closeDate: string;
ownerName: string;
ownerId: string;
lineItems: DealLineItem[];
}
export interface Activity {
id: string;
dealId: string;
type: "note" | "email" | "call" | "meeting";
body: string;
createdAt: string;
}
export interface EnrichmentResult {
summary: string;
sizeEmployees?: number;
recentNews: { title: string; url: string }[];
talkingPoints: string[];
sources: { title: string; url: string }[];
enrichedAt: string;
}
export interface CrmState {
deals: Deal[];
accounts: Account[];
contacts: Contact[];
activities: Activity[];
products: Product[];
salespeople: Salesperson[];
reports: Report[];
quotes: Quote[];
}
export const STAGE_STYLES: Record<Stage, string> = {
Lead: "bg-slate-100 text-slate-700",
Qualified: "bg-sky-100 text-sky-700",
Proposal: "bg-violet-100 text-violet-700",
Negotiation: "bg-amber-100 text-amber-700",
"Closed Won": "bg-emerald-100 text-emerald-700",
"Closed Lost": "bg-rose-100 text-rose-700",
};
export const CATEGORY_STYLES: Record<ProductCategory, string> = {
Laptop: "bg-blue-100 text-blue-700",
Workstation: "bg-indigo-100 text-indigo-700",
Server: "bg-cyan-100 text-cyan-700",
Display: "bg-sky-100 text-sky-700",
Accessory: "bg-slate-100 text-slate-700",
};
export function groupDealsByStage(deals: Deal[]): Record<Stage, Deal[]> {
const out = Object.fromEntries(
STAGES.map((s) => [s, [] as Deal[]]),
) as Record<Stage, Deal[]>;
for (const d of deals) out[d.stage].push(d);
return out;
}
export function formatCurrency(n: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(n);
}
export type Risk = "low" | "medium" | "high";
export function dealRisk(deal: Deal, now: number = Date.now()): Risk {
if (deal.stage === "Closed Won" || deal.stage === "Closed Lost") return "low";
const days = (new Date(deal.closeDate).getTime() - now) / 86_400_000;
if (deal.probability < 35 || days < 0) return "high";
if (deal.probability < 65 || days < 14) return "medium";
return "low";
}
export interface Kpis {
openPipeline: number;
weightedForecast: number;
winRate: number | null;
atRisk: number;
}
export function computeKpis(crm: CrmState, now: number = Date.now()): Kpis {
const open = crm.deals.filter(
(d) => d.stage !== "Closed Won" && d.stage !== "Closed Lost",
);
const won = crm.deals.filter((d) => d.stage === "Closed Won").length;
const lost = crm.deals.filter((d) => d.stage === "Closed Lost").length;
return {
openPipeline: open.reduce((s, d) => s + d.amount, 0),
weightedForecast: Math.round(
open.reduce((s, d) => s + d.amount * (d.probability / 100), 0),
),
winRate: won + lost === 0 ? null : won / (won + lost),
atRisk: open.filter((d) => dealRisk(d, now) !== "low").length,
};
}
/** Apply an optimistic {dealId -> Stage} overlay on top of a base snapshot.
* Returns the same object when the overlay is empty (cheap identity). */
export function applyStageOverlay(
base: CrmState,
overlay: Record<string, Stage>,
): CrmState {
if (Object.keys(overlay).length === 0) return base;
return {
...base,
deals: base.deals.map((d) => {
const s = overlay[d.id];
if (!s || s === d.stage) return d;
const probability =
s === "Closed Won" ? 100 : s === "Closed Lost" ? 0 : d.probability;
return { ...d, stage: s, probability };
}),
};
}
/** Drop overlay entries the base snapshot already reflects (or whose deal is gone),
* so a reflected/superseded optimistic move can never resurrect over a newer snapshot. */
export function pruneOverlay(
base: CrmState,
overlay: Record<string, Stage>,
): Record<string, Stage> {
const next: Record<string, Stage> = {};
for (const [id, stage] of Object.entries(overlay)) {
const d = base.deals.find((x) => x.id === id);
if (d && d.stage !== stage) next[id] = stage;
}
return next;
}
/** Compact relative timestamp ("30m ago", "3h ago", "3d ago"), date for >30d. */
export function relativeTime(iso: string, now: number = Date.now()): string {
const diff = now - new Date(iso).getTime();
const abs = Math.abs(diff);
const MIN = 60_000,
HR = 3_600_000,
DAY = 86_400_000;
const sign = (n: number, unit: string) =>
diff >= 0 ? `${n}${unit} ago` : `in ${n}${unit}`;
if (abs < HR) return sign(Math.max(1, Math.round(abs / MIN)), "m");
if (abs < DAY) return sign(Math.round(abs / HR), "h");
const d = Math.round(abs / DAY);
if (d < 30) return sign(d, "d");
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
// ---------------------------------------------------------------------------
// Analytics helpers (pure; mirrored 1:1 by the backend `src/crm/analytics.ts`).
// All month math is UTC-based and deterministic so charts stay pixel-stable.
// ---------------------------------------------------------------------------
const MONTH_LABELS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const isOpen = (d: Deal): boolean =>
d.stage !== "Closed Won" && d.stage !== "Closed Lost";
const isWon = (d: Deal): boolean => d.stage === "Closed Won";
/** "YYYY-MM" key from an ISO date string (uses the leading 7 chars). */
function monthKey(iso: string): string {
return iso.slice(0, 7);
}
/** The last `count` calendar months ending at `now`, ascending (oldest first). */
function monthBuckets(
now: number,
count = 8,
): { month: string; label: string }[] {
const base = new Date(now);
const y = base.getUTCFullYear();
const m = base.getUTCMonth(); // 0..11
const out: { month: string; label: string }[] = [];
for (let i = count - 1; i >= 0; i--) {
const dt = new Date(Date.UTC(y, m - i, 1));
const mm = String(dt.getUTCMonth() + 1).padStart(2, "0");
out.push({
month: `${dt.getUTCFullYear()}-${mm}`,
label: MONTH_LABELS[dt.getUTCMonth()],
});
}
return out;
}
export interface SalesOverTimePoint {
month: string;
label: string;
bookings: number;
}
/** Closed-Won amount summed by closeDate month, across the last 8 months
* (ascending). Zero months are included so the series is always length 8. */
export function salesOverTime(
crm: CrmState,
now: number = Date.now(),
): SalesOverTimePoint[] {
const buckets = monthBuckets(now, 8);
const sums = new Map<string, number>(buckets.map((b) => [b.month, 0]));
for (const d of crm.deals) {
if (!isWon(d)) continue;
const key = monthKey(d.closeDate);
if (sums.has(key)) sums.set(key, sums.get(key)! + d.amount);
}
return buckets.map((b) => ({
month: b.month,
label: b.label,
bookings: sums.get(b.month)!,
}));
}
export interface CategoryRevenue {
category: ProductCategory;
value: number;
}
/** Open-pipeline revenue (Σ qty × unitPrice of OPEN deals' line items) grouped
* by the product category. Categories that sum to zero are skipped. */
export function revenueByCategory(crm: CrmState): CategoryRevenue[] {
const byProduct = new Map(crm.products.map((p) => [p.id, p]));
const sums = new Map<ProductCategory, number>();
for (const d of crm.deals) {
if (!isOpen(d)) continue;
for (const li of d.lineItems) {
const cat = byProduct.get(li.productId)?.category;
if (!cat) continue;
sums.set(cat, (sums.get(cat) ?? 0) + li.qty * li.unitPrice);
}
}
return [...sums.entries()]
.filter(([, v]) => v > 0)
.map(([category, value]) => ({ category, value }));
}
export interface LeaderboardRow {
salespersonId: string;
name: string;
bookings: number;
openPipeline: number;
attainment: number;
quota: number;
dealCount: number;
}
/** Per-rep aggregates sorted by bookings (Closed-Won $) descending.
* `attainment` = bookings / quota (0 when quota is 0). `now` is accepted for
* signature parity with the other analytics helpers (bookings are all-time). */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function teamLeaderboard(
crm: CrmState,
now: number = Date.now(),
): LeaderboardRow[] {
return crm.salespeople
.map((rep) => {
const deals = crm.deals.filter((d) => d.ownerId === rep.id);
const bookings = deals.filter(isWon).reduce((s, d) => s + d.amount, 0);
const openPipeline = deals
.filter(isOpen)
.reduce((s, d) => s + d.amount, 0);
return {
salespersonId: rep.id,
name: rep.name,
bookings,
openPipeline,
attainment: rep.quota > 0 ? bookings / rep.quota : 0,
quota: rep.quota,
dealCount: deals.length,
};
})
.sort((a, b) => b.bookings - a.bookings);
}
export interface RepStats {
rep: Salesperson;
bookings: number;
openPipeline: number;
attainment: number;
winRate: number | null;
dealCount: number;
trend: number[]; // 8 monthly Closed-Won totals, ascending
deals: Deal[];
}
/** Full stats for one rep, or `null` if the rep id is unknown.
* `trend` is the rep's Closed-Won $ over the last 8 months (ascending). */
export function repStats(
crm: CrmState,
repId: string,
now: number = Date.now(),
): RepStats | null {
const rep = crm.salespeople.find((s) => s.id === repId);
if (!rep) return null;
const deals = crm.deals.filter((d) => d.ownerId === repId);
const wonDeals = deals.filter(isWon);
const bookings = wonDeals.reduce((s, d) => s + d.amount, 0);
const openPipeline = deals.filter(isOpen).reduce((s, d) => s + d.amount, 0);
const won = wonDeals.length;
const lost = deals.filter((d) => d.stage === "Closed Lost").length;
const buckets = monthBuckets(now, 8);
const sums = new Map<string, number>(buckets.map((b) => [b.month, 0]));
for (const d of wonDeals) {
const key = monthKey(d.closeDate);
if (sums.has(key)) sums.set(key, sums.get(key)! + d.amount);
}
return {
rep,
bookings,
openPipeline,
attainment: rep.quota > 0 ? bookings / rep.quota : 0,
winRate: won + lost === 0 ? null : won / (won + lost),
dealCount: deals.length,
trend: buckets.map((b) => sums.get(b.month)!),
deals,
};
}
export interface TeamStats {
totalBookings: number;
weightedForecast: number;
winRate: number | null;
leaderboard: LeaderboardRow[];
byCategory: CategoryRevenue[];
}
/** Whole-team aggregates + per-rep leaderboard (bookings desc, ties broken by
* salesperson id). Mirrors `teamStats` in src/crm/analytics.ts so the Team
* Reports page and the copilot agree to the dollar. */
export function teamStats(crm: CrmState, now: number = Date.now()): TeamStats {
const totalBookings = crm.deals
.filter(isWon)
.reduce((s, d) => s + d.amount, 0);
const weightedForecast = Math.round(
crm.deals
.filter(isOpen)
.reduce((s, d) => s + d.amount * (d.probability / 100), 0),
);
const won = crm.deals.filter(isWon).length;
const lost = crm.deals.filter((d) => d.stage === "Closed Lost").length;
const winRate = won + lost === 0 ? null : won / (won + lost);
const leaderboard = teamLeaderboard(crm, now)
.slice()
.sort(
(a, b) =>
b.bookings - a.bookings ||
a.salespersonId.localeCompare(b.salespersonId),
);
return {
totalBookings,
weightedForecast,
winRate,
leaderboard,
byCategory: revenueByCategory(crm),
};
}