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:
2026-09-01 08:50:57 -04:00
parent 938e642d68
commit d996fb66b6
2 changed files with 211 additions and 21 deletions

View File

@@ -5,9 +5,9 @@ import { PageHeader } from '@/components/ui/PageHeader';
import { ChartCard } from '@/components/ui/ChartCard';
import { Scorecard } from '@/components/ui/Scorecard';
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 { AccountRecord } from '@/types/data';
import { AccountRecord, STAGES, FORECAST_CATEGORIES, ACTIVITY_TYPES } from '@/types/data';
import { ExportButton } from '@/components/ui/ExportButton';
import { scoreAccountHealth, HEALTH_LEVEL_COLORS } from '@/lib/scoring';
import { AccountNotes } from '@/components/account/AccountNotes';
@@ -100,7 +100,7 @@ function getSuggestedPriority(account: AccountRecord): { level: string; reason:
}
export default function AccountExplorer() {
const { filtered } = useData();
const { filtered, refresh } = useData();
const { accounts, pipeline, activities, targets } = filtered;
const [search, setSearch] = useState('');
const [tierFilter, setTierFilter] = useState<string | null>(null);
@@ -112,6 +112,68 @@ export default function AccountExplorer() {
const [priorityFilter, setPriorityFilter] = useState<string | null>(null);
const [competitorFilter, setCompetitorFilter] = useState<string | 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 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">
{/* Pipeline Section */}
<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>
{accountPipeline.length === 0 ? (
<div className="flex items-center justify-between mb-3">
<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="space-y-2">
@@ -397,8 +523,68 @@ export default function AccountExplorer() {
{/* Activity Timeline */}
<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>
{accountActivities.length === 0 ? (
<div className="flex items-center justify-between mb-3">
<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="relative">
@@ -548,7 +734,14 @@ export default function AccountExplorer() {
</div>
{/* 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 */}
<div className="flex flex-wrap gap-2 mb-2">

View File

@@ -51,9 +51,10 @@ interface RenewalPoint {
interface Props {
accounts: AccountRecord[];
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 [minAmount, setMinAmount] = useState(0);
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());
}, [accounts, pipeline]);
const maxAmount = useMemo(() => {
if (allPoints.length === 0) return 100000;
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 SLIDER_MAX = 500000;
const SLIDER_STEP = 50000;
const filteredPoints = useMemo(() => {
return allPoints.filter(p => p.amount >= minAmount);
@@ -263,8 +256,8 @@ export function RenewalTimeline({ accounts, pipeline }: Props) {
<input
type="range"
min={0}
max={maxAmount}
step={sliderStep}
max={SLIDER_MAX}
step={SLIDER_STEP}
value={minAmount}
onChange={e => setMinAmount(Number(e.target.value))}
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}`}
onMouseEnter={e => handleMouseEnter(point, e)}
onMouseLeave={handleMouseLeave}
onClick={() => onSelectAccount?.(point.accountName)}
className="cursor-pointer"
>
<circle
@@ -407,6 +401,7 @@ export function RenewalTimeline({ accounts, pipeline }: Props) {
key={`${point.accountName}-${point.type}-${i}`}
onMouseEnter={e => handleMouseEnter(point, e)}
onMouseLeave={handleMouseLeave}
onClick={() => onSelectAccount?.(point.accountName)}
className="cursor-pointer"
>
<rect
@@ -450,6 +445,8 @@ export function RenewalTimeline({ accounts, pipeline }: Props) {
fill={color}
fontSize={9}
fontWeight={600}
className="cursor-pointer"
onClick={() => onSelectAccount?.(accountName)}
>
{truncated}
</text>