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 { 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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user