Full-featured sales campaign dashboard replacing Looker Studio with responsive Next.js app. Includes Executive Overview, Account Explorer with priority algorithm, Activities with detail overlay, Pipeline/Opps with deal panels, Implementation tracking, and Data Admin with CSV import and CRUD operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
174 lines
5.5 KiB
TypeScript
174 lines
5.5 KiB
TypeScript
'use client';
|
|
|
|
import React, { createContext, useContext, useState, useMemo, useCallback, ReactNode } from 'react';
|
|
import { DashboardData, DashboardConfig, PipelineRecord, AccountRecord, ActivityRecord, TargetRecord } from '@/types/data';
|
|
import { startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfQuarter, endOfQuarter, subDays, parseISO, isWithinInterval } from 'date-fns';
|
|
|
|
export type DatePreset = 'this_week' | 'last_7' | 'this_month' | 'this_quarter' | 'last_quarter' | 'custom';
|
|
|
|
interface FilterState {
|
|
districts: string[];
|
|
datePreset: DatePreset;
|
|
dateRange: { start: string; end: string };
|
|
crossFilter: { key: string; value: string } | null;
|
|
}
|
|
|
|
interface DataContextValue {
|
|
raw: DashboardData;
|
|
config: DashboardConfig;
|
|
filters: FilterState;
|
|
setDistricts: (d: string[]) => void;
|
|
setDatePreset: (p: DatePreset) => void;
|
|
setDateRange: (start: string, end: string) => void;
|
|
setCrossFilter: (key: string, value: string) => void;
|
|
clearCrossFilter: () => void;
|
|
clearAllFilters: () => void;
|
|
filtered: {
|
|
pipeline: PipelineRecord[];
|
|
accounts: AccountRecord[];
|
|
activities: ActivityRecord[];
|
|
targets: TargetRecord[];
|
|
};
|
|
isLoading: boolean;
|
|
lastRefreshed: string;
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
const DataContext = createContext<DataContextValue | null>(null);
|
|
|
|
function getDateRange(preset: DatePreset, custom?: { start: string; end: string }): { start: Date; end: Date } {
|
|
const now = new Date();
|
|
switch (preset) {
|
|
case 'this_week':
|
|
return { start: startOfWeek(now, { weekStartsOn: 1 }), end: endOfWeek(now, { weekStartsOn: 1 }) };
|
|
case 'last_7':
|
|
return { start: subDays(now, 7), end: now };
|
|
case 'this_month':
|
|
return { start: startOfMonth(now), end: endOfMonth(now) };
|
|
case 'this_quarter':
|
|
return { start: startOfQuarter(now), end: endOfQuarter(now) };
|
|
case 'last_quarter': {
|
|
const lastQ = new Date(now);
|
|
lastQ.setMonth(lastQ.getMonth() - 3);
|
|
return { start: startOfQuarter(lastQ), end: endOfQuarter(lastQ) };
|
|
}
|
|
case 'custom':
|
|
if (custom) return { start: parseISO(custom.start), end: parseISO(custom.end) };
|
|
return { start: subDays(now, 30), end: now };
|
|
}
|
|
}
|
|
|
|
export function DataProvider({ children, initialData, config }: { children: ReactNode; initialData: DashboardData; config: DashboardConfig }) {
|
|
const [data, setData] = useState<DashboardData>(initialData);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [filters, setFilters] = useState<FilterState>({
|
|
districts: [],
|
|
datePreset: 'this_quarter',
|
|
dateRange: { start: '', end: '' },
|
|
crossFilter: null,
|
|
});
|
|
|
|
const setDistricts = useCallback((d: string[]) => {
|
|
setFilters(f => ({ ...f, districts: d }));
|
|
}, []);
|
|
|
|
const setDatePreset = useCallback((p: DatePreset) => {
|
|
setFilters(f => ({ ...f, datePreset: p }));
|
|
}, []);
|
|
|
|
const setDateRange = useCallback((start: string, end: string) => {
|
|
setFilters(f => ({ ...f, datePreset: 'custom', dateRange: { start, end } }));
|
|
}, []);
|
|
|
|
const setCrossFilter = useCallback((key: string, value: string) => {
|
|
setFilters(f => ({ ...f, crossFilter: { key, value } }));
|
|
}, []);
|
|
|
|
const clearCrossFilter = useCallback(() => {
|
|
setFilters(f => ({ ...f, crossFilter: null }));
|
|
}, []);
|
|
|
|
const clearAllFilters = useCallback(() => {
|
|
setFilters({ districts: [], datePreset: 'this_quarter', dateRange: { start: '', end: '' }, crossFilter: null });
|
|
}, []);
|
|
|
|
const refresh = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await fetch('/api/sheets?bust=' + Date.now());
|
|
if (res.ok) {
|
|
const newData = await res.json();
|
|
setData(newData);
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const filtered = useMemo(() => {
|
|
const { districts: distFilter, datePreset, dateRange, crossFilter } = filters;
|
|
const range = getDateRange(datePreset, dateRange);
|
|
|
|
let pipeline = data.pipeline;
|
|
let accounts = data.accounts;
|
|
let activities = data.activities;
|
|
let targets = data.targets;
|
|
|
|
if (distFilter.length > 0) {
|
|
pipeline = pipeline.filter(p => distFilter.includes(p.District_Name));
|
|
accounts = accounts.filter(a => distFilter.includes(a.District_Name));
|
|
activities = activities.filter(a => distFilter.includes(a.District_Name));
|
|
}
|
|
|
|
activities = activities.filter(a => {
|
|
try {
|
|
return isWithinInterval(parseISO(a.Activity_Date), range);
|
|
} catch {
|
|
return true;
|
|
}
|
|
});
|
|
|
|
if (crossFilter) {
|
|
const { key, value } = crossFilter;
|
|
const match = (obj: object) => String((obj as never)[key as never]) === value;
|
|
pipeline = pipeline.filter(match);
|
|
accounts = accounts.filter(match);
|
|
activities = activities.filter(match);
|
|
targets = targets.filter(match);
|
|
}
|
|
|
|
if (distFilter.length > 0) {
|
|
const accountNames = new Set(accounts.map(a => a.Account_Name));
|
|
targets = targets.filter(t => accountNames.has(t.Account_Name));
|
|
}
|
|
|
|
return { pipeline, accounts, activities, targets };
|
|
}, [data, filters]);
|
|
|
|
return (
|
|
<DataContext.Provider value={{
|
|
raw: data,
|
|
config,
|
|
filters,
|
|
setDistricts,
|
|
setDatePreset,
|
|
setDateRange,
|
|
setCrossFilter,
|
|
clearCrossFilter,
|
|
clearAllFilters,
|
|
filtered,
|
|
isLoading,
|
|
lastRefreshed: data.lastRefreshed,
|
|
refresh,
|
|
}}>
|
|
{children}
|
|
</DataContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useData() {
|
|
const ctx = useContext(DataContext);
|
|
if (!ctx) throw new Error('useData must be used within DataProvider');
|
|
return ctx;
|
|
}
|