import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { goalsApi } from '../../api/analytics';
import type {
GoalNode,
GoalMetric,
CascadeSummary,
GoalsResponse,
CreateGoalPayload,
AnalyticsUser,
GoalsFilterParams,
} from '../../api/analytics';
import { AppLayout } from '../../components/layout/AppLayout';
import { PageLoader } from '../../components/ui/LoadingSpinner';
import {
Target,
ChevronRight,
ChevronDown,
TrendingUp,
TrendingDown,
CalendarDays,
RefreshCw,
Users,
Building2,
Briefcase,
BarChart3,
Plus,
Trash2,
X,
AlertTriangle,
Search,
Filter,
SlidersHorizontal,
Edit3,
Check,
BarChart2,
Eye,
Link2,
} from 'lucide-react';
// -- Helpers --
const formatCompactCurrency = (value: number) => {
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`;
return `$${value.toFixed(0)}`;
};
const formatDate = (date: Date) =>
date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
function progressColor(pct: number): string {
if (pct >= 80) return 'bg-green-500';
if (pct >= 50) return 'bg-yellow-500';
return 'bg-red-500';
}
function progressTextColor(pct: number): string {
if (pct >= 80) return 'text-green-700';
if (pct >= 50) return 'text-yellow-700';
return 'text-red-700';
}
function varianceBadge(pct: number): { label: string; className: string } {
if (pct >= 100) return { label: 'Ahead', className: 'bg-green-100 text-green-700' };
if (pct >= 80) return { label: 'On Pace', className: 'bg-blue-100 text-blue-700' };
return { label: 'Behind', className: 'bg-red-100 text-red-700' };
}
function levelBadge(level: string): { label: string; className: string; icon: React.ElementType } {
switch (level) {
case 'org':
return { label: 'Org', className: 'bg-purple-100 text-purple-700', icon: Building2 };
case 'department':
return { label: 'Dept', className: 'bg-blue-100 text-blue-700', icon: Briefcase };
case 'team':
return { label: 'Team', className: 'bg-amber-100 text-amber-700', icon: Users };
case 'rep':
return { label: 'Rep', className: 'bg-emerald-100 text-emerald-700', icon: Users };
default:
return { label: level, className: 'bg-surface-100 text-surface-600', icon: Target };
}
}
function statusBadge(status: string): { label: string; className: string } {
switch (status) {
case 'active':
return { label: 'Active', className: 'bg-green-100 text-green-700' };
case 'completed':
return { label: 'Completed', className: 'bg-blue-100 text-blue-700' };
case 'paused':
return { label: 'Paused', className: 'bg-yellow-100 text-yellow-700' };
case 'cancelled':
return { label: 'Cancelled', className: 'bg-red-100 text-red-700' };
default:
return { label: status, className: 'bg-gray-100 text-gray-700' };
}
}
function userInitials(name: string): string {
return name
.split(' ')
.map((w) => w[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
// -- Progress Bar --
function ProgressBar({ value, max, height = 'h-2' }: { value: number; max: number; height?: string }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
return (
<div className={`w-full ${height} rounded-full bg-surface-200 overflow-hidden`}>
<div
className={`${height} rounded-full ${progressColor(pct)} transition-all duration-500`}
style={{ width: `${pct}%` }}
/>
</div>
);
}
// -- Goal Metrics Section --
function GoalMetricsSection({ metrics }: { metrics: GoalMetric[] }) {
if (!metrics || metrics.length === 0) {
return (
<div className="mt-3 py-4 text-center text-sm text-surface-400">
No metrics tracked for this goal yet.
</div>
);
}
return (
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
{metrics.map((m) => (
<div key={m.id} className="rounded-lg border border-surface-200 bg-surface-50 p-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-surface-600">{m.metric_name}</span>
<span className={`text-xs font-bold ${progressTextColor(m.progress_percentage)}`}>
{m.progress_percentage.toFixed(1)}%
</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<ProgressBar value={m.actual_value} max={m.target_value} height="h-1.5" />
<span className="text-xs text-surface-500 whitespace-nowrap">
{formatCompactCurrency(m.actual_value)} / {formatCompactCurrency(m.target_value)}
</span>
</div>
{m.period && <p className="mt-1 text-xs text-surface-400">Period: {m.period}</p>}
</div>
))}
</div>
);
}
// -- Filters Bar --
interface FiltersBarProps {
level: string;
status: string;
search: string;
includeCascade: boolean;
onLevelChange: (level: string) => void;
onStatusChange: (status: string) => void;
onSearchChange: (search: string) => void;
onIncludeCascadeChange: (include: boolean) => void;
onReset: () => void;
}
function FiltersBar({
level,
status,
search,
includeCascade,
onLevelChange,
onStatusChange,
onSearchChange,
onIncludeCascadeChange,
onReset,
}: FiltersBarProps) {
const hasFilters = level || status || search;
return (
<div className="mb-6 rounded-xl border border-surface-200 bg-white p-4">
<div className="flex items-center gap-2 mb-3">
<SlidersHorizontal className="h-4 w-4 text-surface-400" />
<span className="text-sm font-medium text-surface-700">Filters</span>
{hasFilters && (
<button
onClick={onReset}
className="ml-auto text-xs text-brand-600 hover:text-brand-700 font-medium min-h-[24px]"
>
Reset All
</button>
)}
</div>
<div className="flex flex-col sm:flex-row gap-3">
{/* Search */}
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-surface-400" />
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search goals by name or description..."
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2 pl-9 pr-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
/>
</div>
</div>
{/* Level filter */}
<div className="shrink-0">
<select
value={level}
onChange={(e) => onLevelChange(e.target.value)}
className="w-full sm:w-36 rounded-lg border border-surface-200 bg-surface-50 py-2 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
>
<option value="">All Levels</option>
<option value="org">Org</option>
<option value="department">Department</option>
<option value="team">Team</option>
<option value="rep">Rep</option>
</select>
</div>
{/* Status filter */}
<div className="shrink-0">
<select
value={status}
onChange={(e) => onStatusChange(e.target.value)}
className="w-full sm:w-40 rounded-lg border border-surface-200 bg-surface-50 py-2 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="paused">Paused</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
{/* Include cascade toggle */}
<div className="shrink-0 flex items-center gap-2 self-center">
<label className="flex items-center gap-2 text-sm text-surface-600 cursor-pointer">
<input
type="checkbox"
checked={includeCascade}
onChange={(e) => onIncludeCascadeChange(e.target.checked)}
className="rounded border-surface-300 text-brand-600 focus:ring-brand-600"
/>
Cascade tree
</label>
</div>
</div>
</div>
);
}
// -- Goal Edit Modal --
interface GoalEditModalProps {
goal: GoalNode | null;
onClose: () => void;
onSubmit: (payload: Partial<CreateGoalPayload> & { company_id?: string }) => void;
companies: { id: string; name: string }[];
users: AnalyticsUser[];
allGoals: GoalNode[];
isNew: boolean;
}
function GoalEditModal({ goal, onClose, onSubmit, companies, users, allGoals, isNew }: GoalEditModalProps) {
const [name, setName] = useState(goal?.name ?? '');
const [description, setDescription] = useState(goal?.description ?? '');
const [targetValue, setTargetValue] = useState(goal?.target_value?.toString() ?? '');
const [currentValue, setCurrentValue] = useState(goal?.current_value?.toString() ?? '0');
const [level, setLevel] = useState<'org' | 'department' | 'team' | 'rep'>(goal?.level ?? 'org');
const [status, setStatus] = useState(goal?.status ?? 'active');
const [companyId, setCompanyId] = useState(goal?.company_id ?? companies[0]?.id ?? '');
const [assignedTo, setAssignedTo] = useState(goal?.assigned_to ?? '');
const [startDate, setStartDate] = useState(goal?.start_date ?? '');
const [endDate, setEndDate] = useState(goal?.end_date ?? '');
const [unit, setUnit] = useState(goal?.unit ?? '$');
const [weight, setWeight] = useState(goal?.weight?.toString() ?? '1');
const [parentGoalId, setParentGoalId] = useState(goal?.parent_goal_id ?? '');
const [error, setError] = useState('');
// Build parent goals list (exclude own goal and descendants)
const availableParents = useMemo(() => {
const excludeIds = new Set<string>();
if (goal) {
excludeIds.add(goal.id);
const collectSubIds = (g: GoalNode) => {
if (g.sub_goals) {
g.sub_goals.forEach((sub) => {
excludeIds.add(sub.id);
collectSubIds(sub);
});
}
};
collectSubIds(goal);
}
return allGoals.filter((g) => !excludeIds.has(g.id));
}, [allGoals, goal]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Goal name is required');
return;
}
const target = parseFloat(targetValue);
if (isNaN(target) || target <= 0) {
setError('Enter a valid target value');
return;
}
const payload: Partial<CreateGoalPayload> & { company_id?: string } = {
name: name.trim(),
description: description.trim() || undefined,
target_value: target,
current_value: parseFloat(currentValue) || 0,
level,
status,
unit,
weight: parseFloat(weight) || 1,
};
if (isNew && companyId) {
payload.company_id = companyId;
}
if (assignedTo) payload.assigned_to = assignedTo;
if (parentGoalId) payload.parent_goal_id = parentGoalId;
if (startDate) payload.start_date = startDate;
if (endDate) payload.end_date = endDate;
onSubmit(payload);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="rounded-xl border border-surface-200 bg-white p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-surface-900">
{isNew ? 'Create New Goal' : 'Edit Goal'}
</h3>
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-surface-100 min-h-[36px] min-w-[36px] flex items-center justify-center"
>
<X className="h-5 w-5 text-surface-400" />
</button>
</div>
<form onSubmit={handleSubmit}>
{/* Name */}
<div className="mb-4">
<label className="block text-sm font-medium text-surface-700 mb-1">Goal Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Increase Q4 Revenue by 20%"
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
autoFocus
/>
</div>
{/* Description */}
<div className="mb-4">
<label className="block text-sm font-medium text-surface-700 mb-1">Description (optional)</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this goal..."
rows={2}
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px] resize-none"
/>
</div>
{/* Target and Current */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div>
<label className="block text-sm font-medium text-surface-700 mb-1">Target Value *</label>
<input
type="number"
step="any"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
placeholder="100000"
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
/>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 mb-1">Current Value</label>
<input
type="number"
step="any"
value={currentValue}
onChange={(e) => setCurrentValue(e.target.value)}
placeholder="0"
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
/>
</div>
</div>
{/* Level & Status row */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div>
<label className="block text-sm font-medium text-surface-700 mb-1">Level</label>
<select
value={level}
onChange={(e) => setLevel(e.target.value as any)}
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
>
<option value="org">Organization</option>
<option value="department">Department</option>
<option value="team">Team</option>
<option value="rep">Individual Rep</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="paused">Paused</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
{/* Assigned To */}
<div className="mb-4">
<label className="block text-sm font-medium text-surface-700 mb-1">Assigned To</label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
>
<option value="">Unassigned</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.full_name} ({u.email})
</option>
))}
</select>
</div>
{/* Parent Goal */}
<div className="mb-4">
<label className="block text-sm font-medium text-surface-700 mb-1">Parent Goal</label>
<select
value={parentGoalId}
onChange={(e) => setParentGoalId(e.target.value)}
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
>
<option value="">None (top-level goal)</option>
{availableParents.map((g) => (
<option key={g.id} value={g.id}>
{g.name} ({levelBadge(g.level).label})
</option>
))}
</select>
</div>
{/* Weight */}
<div className="mb-4">
<label className="block text-sm font-medium text-surface-700 mb-1">Weight</label>
<input
type="number"
step="any"
value={weight}
onChange={(e) => setWeight(e.target.value)}
placeholder="1"
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
/>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div>
<label className="block text-sm font-medium text-surface-700 mb-1">Start Date</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
/>
</div>
<div>
<label className="block text-sm font-medium text-surface-700 mb-1">End Date</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
/>
</div>
</div>
{/* Unit */}
<div className="mb-4">
<label className="block text-sm font-medium text-surface-700 mb-1">Unit</label>
<select
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
>
<option value="$">USD ($)</option>
<option value="#">Count (#)</option>
<option value="%">Percentage (%)</option>
</select>
</div>
{/* Company (only for create) */}
{isNew && (
<div className="mb-4">
<label className="block text-sm font-medium text-surface-700 mb-1">Company</label>
<select
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
className="w-full rounded-lg border border-surface-200 bg-surface-50 py-2.5 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
>
{companies.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
{/* Error */}
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
{/* Submit */}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-surface-200 px-4 py-2 text-sm font-medium text-surface-700 hover:bg-surface-50 min-h-[44px]"
>
Cancel
</button>
<button
type="submit"
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 min-h-[44px] flex items-center gap-1.5"
>
{isNew ? (
<>
<Plus className="h-4 w-4" />
Create Goal
</>
) : (
<>
<Check className="h-4 w-4" />
Save Changes
</>
)}
</button>
</div>
</form>
</div>
</div>
);
}
// -- Goal Node (Recursive Tree) --
interface GoalTreeNodeProps {
goal: GoalNode;
depth?: number;
onUpdateProgress: (goalId: string, value: number) => void;
onDeleteGoal: (goalId: string) => void;
onEditGoal: (goal: GoalNode) => void;
assignedUserNames: Record<string, string>;
}
function GoalTreeNode({ goal, depth = 0, onUpdateProgress, onDeleteGoal, onEditGoal, assignedUserNames }: GoalTreeNodeProps) {
const [expanded, setExpanded] = useState(depth < 1);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editingProgress, setEditingProgress] = useState(false);
const [progressValue, setProgressValue] = useState(goal.current_value.toString());
const [activeTab, setActiveTab] = useState<'overview' | 'metrics'>('overview');
const hasSubGoals = goal.sub_goals && goal.sub_goals.length > 0;
const badge = levelBadge(goal.level);
const variance = varianceBadge(goal.progress_percentage);
const status = statusBadge(goal.status);
const Icon = badge.icon;
// Get assigned user name
const assignedName = goal.assigned_to
? assignedUserNames[goal.assigned_to] || goal.assigned_to.slice(0, 8) + '...'
: null;
const paddingLeft = depth === 0 ? 'pl-0' : depth === 1 ? 'pl-4' : depth === 2 ? 'pl-8' : 'pl-12';
const toggleExpand = () => {
if (hasSubGoals) setExpanded(!expanded);
};
const handleProgressSubmit = (e: React.FormEvent) => {
e.preventDefault();
const parsed = parseFloat(progressValue);
if (!isNaN(parsed) && parsed >= 0) {
onUpdateProgress(goal.id, parsed);
}
setEditingProgress(false);
};
const handleDelete = () => {
onDeleteGoal(goal.id);
setShowDeleteConfirm(false);
};
return (
<div className={`${paddingLeft}`}>
<div className={`rounded-xl border bg-white p-4 mb-2 hover:border-surface-300 transition-colors ${
depth > 0 ? 'border-surface-100' : 'border-surface-200'
}`}>
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
{/* Expand toggle */}
<button
onClick={toggleExpand}
className={`self-start p-1 rounded-md hover:bg-surface-100 min-h-[44px] min-w-[44px] flex items-center justify-center ${
hasSubGoals ? 'cursor-pointer' : 'cursor-default'
}`}
aria-label={expanded ? 'Collapse' : 'Expand'}
disabled={!hasSubGoals}
>
{hasSubGoals ? (
expanded ? (
<ChevronDown className="h-4 w-4 text-surface-400" />
) : (
<ChevronRight className="h-4 w-4 text-surface-400" />
)
) : (
<div className="h-4 w-4" />
)}
</button>
{/* Cascade connection line for children */}
{depth > 0 && (
<div className="absolute left-0 top-0 bottom-0 w-px bg-surface-200" style={{
left: depth === 1 ? '1.5rem' : depth === 2 ? '3rem' : '4.5rem'
}} />
)}
{/* Goal info */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-1">
<span className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${badge.className}`}>
<Icon className="h-3 w-3" />
{badge.label}
</span>
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${status.className}`}>
{status.label}
</span>
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${variance.className}`}>
{variance.label}
</span>
{/* Assigned to display with avatar */}
{goal.assigned_to && assignedName && (
<span className="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium bg-surface-100 text-surface-600">
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-brand-200 text-brand-700 text-[9px] font-bold">
{userInitials(assignedName)}
</span>
{assignedName}
</span>
)}
{goal.start_date && (
<span className="inline-flex items-center gap-1 text-xs text-surface-400">
<CalendarDays className="h-3 w-3" />
{formatDate(new Date(goal.start_date))}
{goal.end_date && ` → ${formatDate(new Date(goal.end_date))}`}
</span>
)}
</div>
<h4 className="text-sm font-semibold text-surface-900 truncate">{goal.name}</h4>
{goal.description && (
<p className="text-xs text-surface-500 mt-0.5 line-clamp-1">{goal.description}</p>
)}
{/* Sub-goal count */}
{hasSubGoals && (
<div className="mt-1.5 flex items-center gap-1.5">
<Link2 className="h-3 w-3 text-brand-500" />
<span className="text-xs text-brand-600 font-medium">
{goal.sub_goals.length} sub-goal{goal.sub_goals.length !== 1 ? 's' : ''}{' '}
{expanded ? 'expanded' : '(click to expand)'}
</span>
</div>
)}
</div>
{/* Progress */}
<div className="flex items-center gap-2 sm:gap-4">
<div className="text-right min-w-[100px]">
<div className="flex items-center gap-1.5 justify-end">
<span className={`text-lg font-bold ${progressTextColor(goal.progress_percentage)}`}>
{goal.progress_percentage.toFixed(1)}%
</span>
{goal.progress_percentage >= 100 ? (
<TrendingUp className="h-4 w-4 text-green-600" />
) : goal.progress_percentage < 50 ? (
<TrendingDown className="h-4 w-4 text-red-600" />
) : null}
</div>
<div className="text-xs text-surface-500">
{formatCompactCurrency(goal.current_value)} / {formatCompactCurrency(goal.target_value)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => onEditGoal(goal)}
className="p-1.5 rounded-md hover:bg-brand-50 text-surface-400 hover:text-brand-600 min-h-[36px] min-w-[36px] flex items-center justify-center"
title="Edit goal"
>
<Edit3 className="h-4 w-4" />
</button>
<button
onClick={() => setEditingProgress(true)}
className="p-1.5 rounded-md hover:bg-surface-100 text-surface-400 hover:text-surface-600 min-h-[36px] min-w-[36px] flex items-center justify-center"
title="Update progress"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
{showDeleteConfirm ? (
<div className="flex items-center gap-1">
<button
onClick={handleDelete}
className="p-1.5 rounded-md bg-red-100 text-red-600 hover:bg-red-200 min-h-[36px] min-w-[36px] flex items-center justify-center"
title="Confirm delete"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
className="p-1.5 rounded-md hover:bg-surface-100 text-surface-400 min-h-[36px] min-w-[36px] flex items-center justify-center"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-1.5 rounded-md hover:bg-red-50 text-surface-400 hover:text-red-600 min-h-[36px] min-w-[36px] flex items-center justify-center"
title="Delete goal"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
{/* Overview / Metrics tabs */}
{expanded && (
<div className="mt-3">
<div className="flex items-center gap-1 border-b border-surface-200">
<button
onClick={() => setActiveTab('overview')}
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-t-md transition-colors min-h-[36px] ${
activeTab === 'overview'
? 'bg-surface-100 text-brand-700 border-b-2 border-brand-600'
: 'text-surface-500 hover:text-surface-700 hover:bg-surface-50'
}`}
>
<Eye className="h-3 w-3" />
Overview
</button>
<button
onClick={() => setActiveTab('metrics')}
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-t-md transition-colors min-h-[36px] ${
activeTab === 'metrics'
? 'bg-surface-100 text-brand-700 border-b-2 border-brand-600'
: 'text-surface-500 hover:text-surface-700 hover:bg-surface-50'
}`}
>
<BarChart2 className="h-3 w-3" />
Metrics ({goal.metrics?.length || 0})
</button>
</div>
<div className="p-3">
{activeTab === 'overview' && (
<div>
{/* Progress bar */}
<div className="mb-2">
<ProgressBar value={goal.current_value} max={goal.target_value} />
</div>
{/* Detail grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div className="rounded-lg bg-surface-50 p-2">
<p className="text-[10px] uppercase tracking-wider text-surface-400 font-medium">Target</p>
<p className="text-sm font-bold text-surface-900">{formatCompactCurrency(goal.target_value)}</p>
</div>
<div className="rounded-lg bg-surface-50 p-2">
<p className="text-[10px] uppercase tracking-wider text-surface-400 font-medium">Current</p>
<p className="text-sm font-bold text-surface-900">{formatCompactCurrency(goal.current_value)}</p>
</div>
<div className="rounded-lg bg-surface-50 p-2">
<p className="text-[10px] uppercase tracking-wider text-surface-400 font-medium">Remaining</p>
<p className="text-sm font-bold text-surface-900">
{formatCompactCurrency(Math.max(goal.target_value - goal.current_value, 0))}
</p>
</div>
<div className="rounded-lg bg-surface-50 p-2">
<p className="text-[10px] uppercase tracking-wider text-surface-400 font-medium">Weight</p>
<p className="text-sm font-bold text-surface-900">{goal.weight ?? 1}</p>
</div>
</div>
{/* Dates row */}
{(goal.start_date || goal.end_date) && (
<div className="mt-2 flex flex-wrap gap-3 text-xs text-surface-400">
{goal.start_date && (
<span className="flex items-center gap-1">
<CalendarDays className="h-3 w-3" />
Started: {formatDate(new Date(goal.start_date))}
</span>
)}
{goal.end_date && (
<span className="flex items-center gap-1">
<CalendarDays className="h-3 w-3" />
Ends: {formatDate(new Date(goal.end_date))}
</span>
)}
</div>
)}
</div>
)}
{activeTab === 'metrics' && (
<GoalMetricsSection metrics={goal.metrics} />
)}
</div>
</div>
)}
</div>
{/* Edit progress inline */}
{editingProgress && (
<div className="mt-2 rounded-xl border border-brand-200 bg-brand-50 p-4">
<form onSubmit={handleProgressSubmit} className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-xs font-medium text-surface-700 mb-1">
Update Current Value
</label>
<input
type="number"
step="any"
value={progressValue}
onChange={(e) => setProgressValue(e.target.value)}
className="w-full rounded-lg border border-surface-200 bg-white py-2 pl-3 pr-3 text-sm focus:outline-none focus:ring-1 focus:ring-brand-600 min-h-[44px]"
autoFocus
/>
</div>
<div className="flex gap-2 self-end">
<button
type="button"
onClick={() => setEditingProgress(false)}
className="rounded-lg border border-surface-200 px-4 py-2 text-sm font-medium text-surface-700 hover:bg-surface-50 min-h-[44px]"
>
Cancel
</button>
<button
type="submit"
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 min-h-[44px]"
>
Update
</button>
</div>
</form>
</div>
)}
{/* Delete confirmation */}
{showDeleteConfirm && (
<div className="mt-2 rounded-xl border border-red-200 bg-red-50 p-4">
<div className="flex items-start gap-2">
<AlertTriangle className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800">Delete this goal?</p>
<p className="text-xs text-red-600 mt-1">
This will also delete all sub-goals. This cannot be undone.
</p>
</div>
</div>
</div>
)}
{/* Sub-goals (cascade tree) */}
{expanded && hasSubGoals && (
<div className="mt-2 border-l-2 border-brand-100 ml-3 sm:ml-4">
{goal.sub_goals.map((sub) => (
<GoalTreeNode
key={sub.id}
goal={sub}
depth={depth + 1}
onUpdateProgress={onUpdateProgress}
onDeleteGoal={onDeleteGoal}
onEditGoal={onEditGoal}
assignedUserNames={assignedUserNames}
/>
))}
</div>
)}
</div>
);
}
// -- Cascade Summary Card --
function CascadeSummaryCard({ cascade }: { cascade: CascadeSummary }) {
const levels = [
{
label: 'Departments',
targets: cascade.dept_targets,
totalTarget: cascade.dept_total_target,
totalCurrent: cascade.dept_total_current,
icon: Briefcase,
},
{
label: 'Teams',
targets: cascade.team_targets,
totalTarget: cascade.team_total_target,
totalCurrent: cascade.team_total_current,
icon: Users,
},
{
label: 'Reps',
targets: cascade.rep_targets,
totalTarget: cascade.rep_total_target,
totalCurrent: cascade.rep_total_current,
icon: Users,
},
];
return (
<div className="rounded-xl border border-surface-200 bg-white p-5">
<div className="flex items-center gap-2 mb-4">
<BarChart3 className="h-5 w-5 text-brand-600" />
<h3 className="text-sm font-semibold text-surface-900">Cascade Breakdown</h3>
</div>
<div className="space-y-4">
{levels.map((level) => {
const pct = level.totalTarget > 0
? (level.totalCurrent / level.totalTarget) * 100
: 0;
const LevelIcon = level.icon;
const variance = varianceBadge(pct);
return (
<div key={level.label}>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<LevelIcon className="h-4 w-4 text-surface-400" />
<span className="text-sm font-medium text-surface-700">{level.label}</span>
<span className="text-xs text-surface-400">({level.targets.length})</span>
</div>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${variance.className}`}>
{variance.label}
</span>
<span className="text-sm font-bold text-surface-900">{pct.toFixed(1)}%</span>
</div>
</div>
<ProgressBar value={level.totalCurrent} max={level.totalTarget} />
<div className="mt-1 text-xs text-surface-500">
{formatCompactCurrency(level.totalCurrent)} of {formatCompactCurrency(level.totalTarget)}
</div>
</div>
);
})}
</div>
{/* Cascade variance */}
{cascade.cascade_variance !== 0 && (
<div className="mt-4 pt-3 border-t border-surface-200">
<div className="flex items-center justify-between">
<span className="text-xs text-surface-500">Cascade Variance</span>
<span className={`text-xs font-bold ${
cascade.cascade_variance >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{cascade.cascade_variance > 0 ? '+' : ''}{cascade.cascade_variance}%
</span>
</div>
</div>
)}
</div>
);
}
// -- Main Page --
export function GoalsPage() {
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingGoal, setEditingGoal] = useState<GoalNode | null>(null);
const [companies, setCompanies] = useState<{ id: string; name: string }[]>([]);
// Filter state
const [filterLevel, setFilterLevel] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterSearch, setFilterSearch] = useState('');
const [includeCascade, setIncludeCascade] = useState(true);
// Build filter params
const filterParams: GoalsFilterParams = useMemo(() => {
const params: GoalsFilterParams = { include_cascade: includeCascade };
if (filterLevel) params.level = filterLevel as any;
if (filterStatus) params.status = filterStatus as any;
if (filterSearch) params.search = filterSearch;
return params;
}, [filterLevel, filterStatus, filterSearch, includeCascade]);
// Fetch goals with filters
const {
data: goalsData,
isLoading,
error,
} = useQuery<GoalsResponse>({
queryKey: ['analytics-goals', filterParams],
queryFn: () => goalsApi.getGoals(filterParams),
});
// Fetch users for dropdown
const { data: usersData } = useQuery({
queryKey: ['analytics-users'],
queryFn: () => goalsApi.getUsers(),
});
// Build user name map
const users = usersData?.users ?? [];
const assignedUserNames: Record<string, string> = useMemo(() => {
const map: Record<string, string> = {};
users.forEach((u) => {
map[u.id] = u.full_name;
});
return map;
}, [users]);
// Fetch companies for the create form
useEffect(() => {
const fetchCompanies = async () => {
try {
const token = localStorage.getItem('auth_token');
if (!token) return;
const res = await fetch('/api/admin/companies', {
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
if (data.companies) {
setCompanies(data.companies);
}
}
} catch (err) {
console.error('Failed to fetch companies:', err);
}
};
fetchCompanies();
}, []);
// Update progress mutation
const updateProgressMutation = useMutation({
mutationFn: ({ goalId, value }: { goalId: string; value: number }) =>
goalsApi.updateProgress(goalId, value, true),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['analytics-goals'] });
},
});
// Create goal mutation
const createGoalMutation = useMutation({
mutationFn: (payload: CreateGoalPayload) => goalsApi.createGoal(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['analytics-goals'] });
setShowCreateModal(false);
},
});
// Update goal mutation
const updateGoalMutation = useMutation({
mutationFn: ({ goalId, payload }: { goalId: string; payload: Partial<CreateGoalPayload> & { company_id?: string } }) =>
goalsApi.updateGoal(goalId, payload as any),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['analytics-goals'] });
setEditingGoal(null);
},
});
// Delete goal mutation
const deleteGoalMutation = useMutation({
mutationFn: (goalId: string) => goalsApi.deleteGoal(goalId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['analytics-goals'] });
},
});
const handleUpdateProgress = useCallback(
(goalId: string, value: number) => {
updateProgressMutation.mutate({ goalId, value });
},
[updateProgressMutation]
);
const handleCreateGoal = useCallback(
(payload: CreateGoalPayload) => {
createGoalMutation.mutate(payload);
},
[createGoalMutation]
);
const handleUpdateGoal = useCallback(
(payload: Partial<CreateGoalPayload> & { company_id?: string }) => {
if (editingGoal) {
updateGoalMutation.mutate({ goalId: editingGoal.id, payload });
}
},
[editingGoal, updateGoalMutation]
);
const handleDeleteGoal = useCallback(
(goalId: string) => {
deleteGoalMutation.mutate(goalId);
},
[deleteGoalMutation]
);
const handleEditGoal = useCallback(
(goal: GoalNode) => {
setEditingGoal(goal);
},
[]
);
const handleResetFilters = useCallback(() => {
setFilterLevel('');
setFilterStatus('');
setFilterSearch('');
setIncludeCascade(true);
}, []);
if (isLoading) {
return (
<AppLayout title="Cascading Goals">
<PageLoader />
</AppLayout>
);
}
if (error) {
return (
<AppLayout title="Cascading Goals">
<div className="rounded-xl border border-red-200 bg-red-50 p-6 text-center">
<p className="text-red-700 font-medium">Failed to load goals</p>
<p className="text-red-600 text-sm mt-1">{(error as Error).message}</p>
</div>
</AppLayout>
);
}
const cascade = goalsData?.cascade_summary;
const goals = goalsData?.goals ?? [];
return (
<AppLayout title="Cascading Goals">
{/* Header */}
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-surface-900">Cascading Goals</h1>
<p className="mt-1 text-sm text-surface-500">
Forecast → Cascade → Track
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border border-surface-200 bg-white px-3 py-2 min-h-[44px]">
<CalendarDays className="h-4 w-4 text-surface-400" />
<span className="text-sm text-surface-600">
Updated {formatDate(new Date())}
</span>
</div>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['analytics-goals'] })}
className="inline-flex items-center gap-1.5 rounded-lg border border-surface-200 bg-white px-3 py-2 text-sm font-medium text-surface-700 hover:bg-surface-50 min-h-[44px]"
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700 min-h-[44px]"
>
<Plus className="h-4 w-4" />
New Goal
</button>
</div>
</div>
{/* Filters Bar */}
<FiltersBar
level={filterLevel}
status={filterStatus}
search={filterSearch}
includeCascade={includeCascade}
onLevelChange={setFilterLevel}
onStatusChange={setFilterStatus}
onSearchChange={setFilterSearch}
onIncludeCascadeChange={setIncludeCascade}
onReset={handleResetFilters}
/>
{/* Org Overview Card */}
{cascade && (
<div className="mb-8">
<div className="rounded-xl border border-surface-200 bg-white p-5 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
{/* Status indicator */}
<div className="flex items-center gap-3">
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full text-xl ${
cascade.org_progress_percentage >= 80
? 'bg-green-100'
: cascade.org_progress_percentage >= 50
? 'bg-yellow-100'
: 'bg-red-100'
}`}>
{cascade.org_progress_percentage >= 80 ? '🟢' : cascade.org_progress_percentage >= 50 ? '🟡' : '🔴'}
</div>
<div>
<h3 className="text-base font-bold text-surface-900">
Org Revenue Target
</h3>
<p className="text-sm text-surface-500">
{formatCompactCurrency(cascade.org_current)} of {formatCompactCurrency(cascade.org_target)}
</p>
</div>
</div>
{/* Progress */}
<div className="flex-1">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-medium text-surface-700">Overall Progress</span>
<span className={`text-sm font-bold ${progressTextColor(cascade.org_progress_percentage)}`}>
{cascade.org_progress_percentage.toFixed(1)}%
</span>
</div>
<ProgressBar value={cascade.org_current} max={cascade.org_target} />
</div>
</div>
{/* Summary stats */}
<div className="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-lg bg-surface-50 p-3">
<p className="text-xs text-surface-500">Org Target</p>
<p className="text-sm font-bold text-surface-900">{formatCompactCurrency(cascade.org_target)}</p>
</div>
<div className="rounded-lg bg-surface-50 p-3">
<p className="text-xs text-surface-500">Current</p>
<p className="text-sm font-bold text-surface-900">{formatCompactCurrency(cascade.org_current)}</p>
</div>
<div className="rounded-lg bg-surface-50 p-3">
<p className="text-xs text-surface-500">Total Goals</p>
<p className="text-sm font-bold text-surface-900">{goalsData?.total_goals ?? 0}</p>
</div>
<div className="rounded-lg bg-surface-50 p-3">
<p className="text-xs text-surface-500">Cascade Variance</p>
<p className={`text-sm font-bold ${
cascade.cascade_variance >= 0 ? 'text-green-600' : 'text-red-600'
}`}>
{cascade.cascade_variance > 0 ? '+' : ''}{cascade.cascade_variance}%
</p>
</div>
</div>
</div>
</div>
)}
{/* Cascade Summary */}
{cascade && (
<div className="mb-8">
<CascadeSummaryCard cascade={cascade} />
</div>
)}
{/* Cascading Tree */}
<div className="mb-8">
<div className="mb-4 flex items-center gap-2">
<Target className="h-5 w-5 text-brand-600" />
<h2 className="text-lg font-semibold text-surface-900">Goal Hierarchy</h2>
<span className="text-xs text-surface-400 ml-auto">
{goals.length} org goal{goals.length !== 1 ? 's' : ''}
</span>
</div>
{goals.length === 0 ? (
<div className="rounded-xl border border-surface-200 bg-white p-8 text-center">
<Target className="h-12 w-12 text-surface-300 mx-auto mb-3" />
<p className="text-surface-500 font-medium">No goals configured</p>
<p className="text-surface-400 text-sm mt-1 mb-4">Set up your org goals to start tracking progress</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 min-h-[44px]"
>
<Plus className="h-4 w-4" />
Create First Goal
</button>
</div>
) : (
goals.map((goal) => (
<GoalTreeNode
key={goal.id}
goal={goal}
onUpdateProgress={handleUpdateProgress}
onDeleteGoal={handleDeleteGoal}
onEditGoal={handleEditGoal}
assignedUserNames={assignedUserNames}
/>
))
)}
</div>
{/* Create Goal Modal */}
{showCreateModal && (
<GoalEditModal
goal={null}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateGoal}
companies={companies}
users={users}
allGoals={goals}
isNew={true}
/>
)}
{/* Edit Goal Modal */}
{editingGoal && (
<GoalEditModal
goal={editingGoal}
onClose={() => setEditingGoal(null)}
onSubmit={handleUpdateGoal}
companies={companies}
users={users}
allGoals={goals}
isNew={false}
/>
)}
</AppLayout>
);
}