Enhance renewal timeline and add inline create forms
- Slider now filters $0-$500K in $50K increments (10 steps) - Timeline data points and account labels are clickable to open account detail - Add "Create Opp" button in Pipeline section with inline form (stage, amount, forecast, probability, close date, product, next step) - Add "Add Activity" button in Activity Timeline with inline form (type, date, contact, outcome, notes) - Both forms save via admin API and refresh dashboard data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,9 @@ import { PageHeader } from '@/components/ui/PageHeader';
|
|||||||
import { ChartCard } from '@/components/ui/ChartCard';
|
import { ChartCard } from '@/components/ui/ChartCard';
|
||||||
import { Scorecard } from '@/components/ui/Scorecard';
|
import { Scorecard } from '@/components/ui/Scorecard';
|
||||||
import { formatCurrency, CHART_COLORS, STATUS_COLORS, DISTRICT_SHORT, STAGE_COLORS, TIER_COLORS } from '@/lib/formatters';
|
import { formatCurrency, CHART_COLORS, STATUS_COLORS, DISTRICT_SHORT, STAGE_COLORS, TIER_COLORS } from '@/lib/formatters';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState, useCallback } from 'react';
|
||||||
import { parseISO, format, differenceInDays, eachDayOfInterval, addMonths, addQuarters } from 'date-fns';
|
import { parseISO, format, differenceInDays, eachDayOfInterval, addMonths, addQuarters } from 'date-fns';
|
||||||
import { AccountRecord } from '@/types/data';
|
import { AccountRecord, STAGES, FORECAST_CATEGORIES, ACTIVITY_TYPES } from '@/types/data';
|
||||||
import { ExportButton } from '@/components/ui/ExportButton';
|
import { ExportButton } from '@/components/ui/ExportButton';
|
||||||
import { scoreAccountHealth, HEALTH_LEVEL_COLORS } from '@/lib/scoring';
|
import { scoreAccountHealth, HEALTH_LEVEL_COLORS } from '@/lib/scoring';
|
||||||
import { AccountNotes } from '@/components/account/AccountNotes';
|
import { AccountNotes } from '@/components/account/AccountNotes';
|
||||||
@@ -100,7 +100,7 @@ function getSuggestedPriority(account: AccountRecord): { level: string; reason:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AccountExplorer() {
|
export default function AccountExplorer() {
|
||||||
const { filtered } = useData();
|
const { filtered, refresh } = useData();
|
||||||
const { accounts, pipeline, activities, targets } = filtered;
|
const { accounts, pipeline, activities, targets } = filtered;
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [tierFilter, setTierFilter] = useState<string | null>(null);
|
const [tierFilter, setTierFilter] = useState<string | null>(null);
|
||||||
@@ -112,6 +112,68 @@ export default function AccountExplorer() {
|
|||||||
const [priorityFilter, setPriorityFilter] = useState<string | null>(null);
|
const [priorityFilter, setPriorityFilter] = useState<string | null>(null);
|
||||||
const [competitorFilter, setCompetitorFilter] = useState<string | null>(null);
|
const [competitorFilter, setCompetitorFilter] = useState<string | null>(null);
|
||||||
const [selectedAccount, setSelectedAccount] = useState<AccountRecord | null>(null);
|
const [selectedAccount, setSelectedAccount] = useState<AccountRecord | null>(null);
|
||||||
|
const [showCreateOpp, setShowCreateOpp] = useState(false);
|
||||||
|
const [showAddActivity, setShowAddActivity] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const createOpp = useCallback(async (form: Record<string, string>) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const oppId = `OPP-${Date.now()}`;
|
||||||
|
await fetch('/api/admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'upsert',
|
||||||
|
table: 'pipeline',
|
||||||
|
record: {
|
||||||
|
Opportunity_ID: oppId,
|
||||||
|
Account_Name: selectedAccount?.Account_Name,
|
||||||
|
District_Name: selectedAccount?.District_Name,
|
||||||
|
Stage: form.stage || '01-Qualified',
|
||||||
|
Forecast_Category: form.forecast || 'Pipeline',
|
||||||
|
Amount_USD: Number(form.amount) || 0,
|
||||||
|
Probability_Pct: Number(form.probability) || 10,
|
||||||
|
Created_Date: new Date().toISOString().split('T')[0],
|
||||||
|
Expected_Close_Date: form.closeDate || '',
|
||||||
|
Next_Step: form.nextStep || null,
|
||||||
|
Product: form.product || '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
setShowCreateOpp(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [selectedAccount, refresh]);
|
||||||
|
|
||||||
|
const addNewActivity = useCallback(async (form: Record<string, string>) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'upsert',
|
||||||
|
table: 'activities',
|
||||||
|
record: {
|
||||||
|
Activity_Date: form.date || new Date().toISOString().split('T')[0],
|
||||||
|
Activity_Type: form.type || 'Call',
|
||||||
|
Account_Name: selectedAccount?.Account_Name,
|
||||||
|
District_Name: selectedAccount?.District_Name,
|
||||||
|
Contact_Name: form.contact || null,
|
||||||
|
Notes: form.notes || null,
|
||||||
|
Outcome: form.outcome || null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await refresh();
|
||||||
|
setShowAddActivity(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [selectedAccount, refresh]);
|
||||||
|
|
||||||
const adValues = useMemo(() => Array.from(new Set(accounts.map(a => a.AD).filter(Boolean))).sort() as string[], [accounts]);
|
const adValues = useMemo(() => Array.from(new Set(accounts.map(a => a.AD).filter(Boolean))).sort() as string[], [accounts]);
|
||||||
const imsbaValues = useMemo(() => Array.from(new Set(accounts.map(a => a.IMS_BA).filter(Boolean))).sort() as string[], [accounts]);
|
const imsbaValues = useMemo(() => Array.from(new Set(accounts.map(a => a.IMS_BA).filter(Boolean))).sort() as string[], [accounts]);
|
||||||
@@ -366,8 +428,72 @@ export default function AccountExplorer() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{/* Pipeline Section */}
|
{/* Pipeline Section */}
|
||||||
<div className="bg-card-bg rounded-xl border border-card-border p-5">
|
<div className="bg-card-bg rounded-xl border border-card-border p-5">
|
||||||
<h3 className="text-sm font-semibold mb-3">Pipeline ({accountPipeline.length} opportunities)</h3>
|
<div className="flex items-center justify-between mb-3">
|
||||||
{accountPipeline.length === 0 ? (
|
<h3 className="text-sm font-semibold">Pipeline ({accountPipeline.length} opportunities)</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateOpp(!showCreateOpp)}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-[10px] font-semibold bg-brand-azure text-white hover:bg-brand-azure/90 transition"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||||
|
Create Opp
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showCreateOpp && (
|
||||||
|
<form
|
||||||
|
className="border border-brand-azure/30 rounded-lg p-3 mb-3 bg-brand-azure/5"
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
const form: Record<string, string> = {};
|
||||||
|
fd.forEach((v, k) => { form[k] = v.toString(); });
|
||||||
|
createOpp(form);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted uppercase">Amount ($)</label>
|
||||||
|
<input name="amount" type="number" required className="w-full text-xs border border-card-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-brand-azure" placeholder="250000" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted uppercase">Stage</label>
|
||||||
|
<select name="stage" className="w-full text-xs border border-card-border rounded px-2 py-1.5 bg-white">
|
||||||
|
{STAGES.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted uppercase">Forecast Category</label>
|
||||||
|
<select name="forecast" className="w-full text-xs border border-card-border rounded px-2 py-1.5 bg-white">
|
||||||
|
{FORECAST_CATEGORIES.map(f => <option key={f} value={f}>{f}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted uppercase">Probability %</label>
|
||||||
|
<input name="probability" type="number" min="0" max="100" defaultValue="10" className="w-full text-xs border border-card-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-brand-azure" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted uppercase">Expected Close</label>
|
||||||
|
<input name="closeDate" type="date" className="w-full text-xs border border-card-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-brand-azure" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted uppercase">Product</label>
|
||||||
|
<input name="product" type="text" className="w-full text-xs border border-card-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-brand-azure" placeholder="VCF" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="text-[10px] text-muted uppercase">Next Step</label>
|
||||||
|
<input name="nextStep" type="text" className="w-full text-xs border border-card-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-brand-azure" placeholder="Schedule discovery call" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="submit" disabled={saving} className="px-3 py-1.5 rounded-lg text-xs font-semibold bg-brand-azure text-white hover:bg-brand-azure/90 transition disabled:opacity-50">
|
||||||
|
{saving ? 'Saving...' : 'Save Opportunity'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowCreateOpp(false)} className="px-3 py-1.5 rounded-lg text-xs text-muted hover:bg-gray-100 transition">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{accountPipeline.length === 0 && !showCreateOpp ? (
|
||||||
<div className="text-xs text-muted py-4 text-center">No pipeline opportunities</div>
|
<div className="text-xs text-muted py-4 text-center">No pipeline opportunities</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -397,8 +523,68 @@ export default function AccountExplorer() {
|
|||||||
|
|
||||||
{/* Activity Timeline */}
|
{/* Activity Timeline */}
|
||||||
<div className="bg-card-bg rounded-xl border border-card-border p-5">
|
<div className="bg-card-bg rounded-xl border border-card-border p-5">
|
||||||
<h3 className="text-sm font-semibold mb-3">Activity Timeline ({accountActivities.length} activities)</h3>
|
<div className="flex items-center justify-between mb-3">
|
||||||
{accountActivities.length === 0 ? (
|
<h3 className="text-sm font-semibold">Activity Timeline ({accountActivities.length} activities)</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddActivity(!showAddActivity)}
|
||||||
|
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-[10px] font-semibold bg-brand-green text-white hover:bg-brand-green/90 transition"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
|
||||||
|
Add Activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showAddActivity && (
|
||||||
|
<form
|
||||||
|
className="border border-brand-green/30 rounded-lg p-3 mb-3 bg-brand-green/5"
|
||||||
|
onSubmit={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
const form: Record<string, string> = {};
|
||||||
|
fd.forEach((v, k) => { form[k] = v.toString(); });
|
||||||
|
addNewActivity(form);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted uppercase">Activity Type</label>
|
||||||
|
<select name="type" className="w-full text-xs border border-card-border rounded px-2 py-1.5 bg-white">
|
||||||
|
{ACTIVITY_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted uppercase">Date</label>
|
||||||
|
<input name="date" type="date" defaultValue={new Date().toISOString().split('T')[0]} className="w-full text-xs border border-card-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-brand-green" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted uppercase">Contact Name</label>
|
||||||
|
<input name="contact" type="text" className="w-full text-xs border border-card-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-brand-green" placeholder="Jane Smith" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[10px] text-muted uppercase">Outcome</label>
|
||||||
|
<select name="outcome" className="w-full text-xs border border-card-border rounded px-2 py-1.5 bg-white">
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
<option value="Positive">Positive</option>
|
||||||
|
<option value="Neutral">Neutral</option>
|
||||||
|
<option value="Negative">Negative</option>
|
||||||
|
<option value="Follow-up Needed">Follow-up Needed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="text-[10px] text-muted uppercase">Notes</label>
|
||||||
|
<textarea name="notes" rows={2} className="w-full text-xs border border-card-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-brand-green resize-none" placeholder="Meeting summary, action items..." />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="submit" disabled={saving} className="px-3 py-1.5 rounded-lg text-xs font-semibold bg-brand-green text-white hover:bg-brand-green/90 transition disabled:opacity-50">
|
||||||
|
{saving ? 'Saving...' : 'Save Activity'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowAddActivity(false)} className="px-3 py-1.5 rounded-lg text-xs text-muted hover:bg-gray-100 transition">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{accountActivities.length === 0 && !showAddActivity ? (
|
||||||
<div className="text-xs text-muted py-4 text-center">No activities logged</div>
|
<div className="text-xs text-muted py-4 text-center">No activities logged</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -548,7 +734,14 @@ export default function AccountExplorer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Renewal Timeline */}
|
{/* Renewal Timeline */}
|
||||||
<RenewalTimeline accounts={filteredAccounts} pipeline={pipeline} />
|
<RenewalTimeline
|
||||||
|
accounts={filteredAccounts}
|
||||||
|
pipeline={pipeline}
|
||||||
|
onSelectAccount={(name) => {
|
||||||
|
const acct = filteredAccounts.find(a => a.Account_Name === name);
|
||||||
|
if (acct) setSelectedAccount(acct);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Search & Filters */}
|
{/* Search & Filters */}
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ interface RenewalPoint {
|
|||||||
interface Props {
|
interface Props {
|
||||||
accounts: AccountRecord[];
|
accounts: AccountRecord[];
|
||||||
pipeline: PipelineRecord[];
|
pipeline: PipelineRecord[];
|
||||||
|
onSelectAccount?: (accountName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RenewalTimeline({ accounts, pipeline }: Props) {
|
export function RenewalTimeline({ accounts, pipeline, onSelectAccount }: Props) {
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const [minAmount, setMinAmount] = useState(0);
|
const [minAmount, setMinAmount] = useState(0);
|
||||||
const [hoveredPoint, setHoveredPoint] = useState<RenewalPoint | null>(null);
|
const [hoveredPoint, setHoveredPoint] = useState<RenewalPoint | null>(null);
|
||||||
@@ -87,16 +88,8 @@ export function RenewalTimeline({ accounts, pipeline }: Props) {
|
|||||||
return points.sort((a, b) => a.date.getTime() - b.date.getTime());
|
return points.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||||
}, [accounts, pipeline]);
|
}, [accounts, pipeline]);
|
||||||
|
|
||||||
const maxAmount = useMemo(() => {
|
const SLIDER_MAX = 500000;
|
||||||
if (allPoints.length === 0) return 100000;
|
const SLIDER_STEP = 50000;
|
||||||
return Math.max(...allPoints.map(p => p.amount));
|
|
||||||
}, [allPoints]);
|
|
||||||
|
|
||||||
const sliderStep = useMemo(() => {
|
|
||||||
if (maxAmount <= 100000) return 5000;
|
|
||||||
if (maxAmount <= 1000000) return 25000;
|
|
||||||
return 100000;
|
|
||||||
}, [maxAmount]);
|
|
||||||
|
|
||||||
const filteredPoints = useMemo(() => {
|
const filteredPoints = useMemo(() => {
|
||||||
return allPoints.filter(p => p.amount >= minAmount);
|
return allPoints.filter(p => p.amount >= minAmount);
|
||||||
@@ -263,8 +256,8 @@ export function RenewalTimeline({ accounts, pipeline }: Props) {
|
|||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={maxAmount}
|
max={SLIDER_MAX}
|
||||||
step={sliderStep}
|
step={SLIDER_STEP}
|
||||||
value={minAmount}
|
value={minAmount}
|
||||||
onChange={e => setMinAmount(Number(e.target.value))}
|
onChange={e => setMinAmount(Number(e.target.value))}
|
||||||
className="w-32 h-1.5 accent-brand-azure"
|
className="w-32 h-1.5 accent-brand-azure"
|
||||||
@@ -378,6 +371,7 @@ export function RenewalTimeline({ accounts, pipeline }: Props) {
|
|||||||
key={`${point.accountName}-${point.type}-${i}`}
|
key={`${point.accountName}-${point.type}-${i}`}
|
||||||
onMouseEnter={e => handleMouseEnter(point, e)}
|
onMouseEnter={e => handleMouseEnter(point, e)}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onClick={() => onSelectAccount?.(point.accountName)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
@@ -407,6 +401,7 @@ export function RenewalTimeline({ accounts, pipeline }: Props) {
|
|||||||
key={`${point.accountName}-${point.type}-${i}`}
|
key={`${point.accountName}-${point.type}-${i}`}
|
||||||
onMouseEnter={e => handleMouseEnter(point, e)}
|
onMouseEnter={e => handleMouseEnter(point, e)}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onClick={() => onSelectAccount?.(point.accountName)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<rect
|
<rect
|
||||||
@@ -450,6 +445,8 @@ export function RenewalTimeline({ accounts, pipeline }: Props) {
|
|||||||
fill={color}
|
fill={color}
|
||||||
fontSize={9}
|
fontSize={9}
|
||||||
fontWeight={600}
|
fontWeight={600}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => onSelectAccount?.(accountName)}
|
||||||
>
|
>
|
||||||
{truncated}
|
{truncated}
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
Reference in New Issue
Block a user