769 lines
33 KiB
TypeScript
769 lines
33 KiB
TypeScript
'use client';
|
|
|
|
import { use, useState, useEffect, useCallback } from 'react';
|
|
import { Card } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Loader2, CheckCircle2, Circle, Clock, RefreshCw, Eye, Cog, GitBranch, ChevronDown, ChevronRight } from 'lucide-react';
|
|
import { CollapsibleSidebar } from '@/components/ui/collapsible-sidebar';
|
|
|
|
interface WorkItem {
|
|
id: string;
|
|
title: string;
|
|
category: string;
|
|
path: string;
|
|
status: 'built' | 'missing' | 'in_progress';
|
|
priority: string;
|
|
assigned?: string;
|
|
startDate: string | null;
|
|
endDate: string | null;
|
|
duration: number;
|
|
sessionsCount: number;
|
|
commitsCount: number;
|
|
totalActivity: number;
|
|
estimatedCost?: number;
|
|
requirements: Array<{
|
|
id: number;
|
|
text: string;
|
|
status: 'built' | 'missing' | 'in_progress';
|
|
}>;
|
|
evidence: string[];
|
|
note?: string;
|
|
}
|
|
|
|
interface TimelineData {
|
|
workItems: WorkItem[];
|
|
timeline: {
|
|
start: string;
|
|
end: string;
|
|
totalDays: number;
|
|
};
|
|
summary: {
|
|
totalWorkItems: number;
|
|
withActivity: number;
|
|
noActivity: number;
|
|
built: number;
|
|
missing: number;
|
|
};
|
|
projectCreator?: string;
|
|
}
|
|
|
|
export default function TimelinePlanPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ workspace: string; projectId: string }>;
|
|
}) {
|
|
const { projectId } = use(params);
|
|
const [data, setData] = useState<TimelineData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [regenerating, setRegenerating] = useState(false);
|
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
|
const [viewMode, setViewMode] = useState<'touchpoints' | 'technical' | 'journey'>('touchpoints');
|
|
const [collapsedJourneySections, setCollapsedJourneySections] = useState<Set<string>>(new Set());
|
|
|
|
// Map work items to types based on path and category
|
|
const getWorkItemType = (item: WorkItem): string => {
|
|
// API endpoints are System
|
|
if (item.path.startsWith('/api/')) return 'System';
|
|
|
|
// Flows are Flow
|
|
if (item.path.startsWith('flow/')) return 'Flow';
|
|
|
|
// Auth/OAuth is System
|
|
if (item.path.includes('auth') || item.path.includes('oauth')) return 'System';
|
|
|
|
// Settings is System
|
|
if (item.path.includes('settings')) return 'System';
|
|
|
|
// Marketing/Content pages
|
|
if (item.category === 'Marketing' || item.category === 'Content') return 'Screen';
|
|
|
|
// Social
|
|
if (item.category === 'Social') return 'Screen';
|
|
|
|
// Everything else is a Screen
|
|
return 'Screen';
|
|
};
|
|
|
|
// Determine if item is a user-facing touchpoint
|
|
const isTouchpoint = (item: WorkItem): boolean => {
|
|
const path = item.path.toLowerCase();
|
|
const title = item.title.toLowerCase();
|
|
|
|
// Exclude APIs and backend systems
|
|
if (path.startsWith('/api/')) return false;
|
|
if (title.includes(' api') || title.includes('api ')) return false;
|
|
|
|
// Exclude pure auth infrastructure (OAuth endpoints)
|
|
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return false;
|
|
|
|
// Include everything else - screens, pages, social posts, blogs, invites, etc.
|
|
return true;
|
|
};
|
|
|
|
// Determine if item is technical infrastructure
|
|
const isTechnical = (item: WorkItem): boolean => {
|
|
const path = item.path.toLowerCase();
|
|
const title = item.title.toLowerCase();
|
|
|
|
// APIs and backend
|
|
if (path.startsWith('/api/')) return true;
|
|
if (title.includes(' api') || title.includes('api ')) return true;
|
|
|
|
// Auth infrastructure
|
|
if (path.includes('oauth') && !path.includes('button') && !path.includes('signin')) return true;
|
|
|
|
// System settings
|
|
if (item.category === 'Settings' && title.includes('api')) return true;
|
|
|
|
return false;
|
|
};
|
|
|
|
// Map work items to customer lifecycle journey sections
|
|
const getJourneySection = (item: WorkItem): string => {
|
|
const title = item.title.toLowerCase();
|
|
const path = item.path.toLowerCase();
|
|
|
|
// Discovery - "I just found you online via social post, blog article, advertisement"
|
|
if (path === '/' || title.includes('landing') || title.includes('marketing page')) return 'Discovery';
|
|
if (item.category === 'Social' && !path.includes('settings')) return 'Discovery';
|
|
if (item.category === 'Content' && (title.includes('blog') || title.includes('article'))) return 'Discovery';
|
|
|
|
// Research - "Checking out your marketing website - features, price, home page"
|
|
if (title.includes('marketing dashboard')) return 'Research';
|
|
if (item.category === 'Marketing' && path !== '/') return 'Research';
|
|
if (path.includes('/features') || path.includes('/pricing') || path.includes('/about')) return 'Research';
|
|
if (item.category === 'Content' && path.includes('/docs') && !title.includes('getting started')) return 'Research';
|
|
|
|
// Onboarding - "Creating an account to try the product for the first time"
|
|
if (path.includes('auth') || path.includes('oauth')) return 'Onboarding';
|
|
if (path.includes('signup') || path.includes('signin') || path.includes('login')) return 'Onboarding';
|
|
if (title.includes('authentication') && !title.includes('api')) return 'Onboarding';
|
|
|
|
// First Use - "Zero state to experiencing the magic solution"
|
|
if (title.includes('onboarding')) return 'First Use';
|
|
if (title.includes('getting started')) return 'First Use';
|
|
if (path.includes('workspace') && !path.includes('settings')) return 'First Use';
|
|
if (title.includes('creation flow') || title.includes('project creation')) return 'First Use';
|
|
if (path.includes('/projects') && path.match(/\/projects\/?$/)) return 'First Use'; // Projects list page
|
|
|
|
// Active - "I've seen the magic and come back to use it again and again"
|
|
if (path.includes('overview') || path.includes('/dashboard')) return 'Active';
|
|
if (path.includes('timeline-plan') || path.includes('audit') || path.includes('mission')) return 'Active';
|
|
if (path.includes('/api/projects') || path.includes('mvp-checklist')) return 'Active';
|
|
if (title.includes('plan generation') || title.includes('marketing plan')) return 'Active';
|
|
if (path.includes('projects/') && path.length > '/projects/'.length) return 'Active'; // Specific project pages
|
|
|
|
// Support - "I've got questions, need quick answers to get back to the magic"
|
|
if (path.includes('settings')) return 'Support';
|
|
if (path.includes('/help') || path.includes('/faq') || path.includes('/support')) return 'Support';
|
|
if (item.category === 'Content' && path.includes('/docs') && title.includes('help')) return 'Support';
|
|
|
|
// Purchase - "Time to pay so I can keep using the magic"
|
|
if (path.includes('billing') || path.includes('payment') || path.includes('subscription')) return 'Purchase';
|
|
if (path.includes('upgrade') || path.includes('checkout') || path.includes('pricing/buy')) return 'Purchase';
|
|
|
|
// Default to Active for core product features
|
|
return 'Active';
|
|
};
|
|
|
|
const toggleJourneySection = (sectionId: string) => {
|
|
setCollapsedJourneySections(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(sectionId)) {
|
|
newSet.delete(sectionId);
|
|
} else {
|
|
newSet.add(sectionId);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
// Get emoji icon for journey section
|
|
const getJourneySectionIcon = (section: string): string => {
|
|
const icons: Record<string, string> = {
|
|
'Discovery': '🔍',
|
|
'Research': '📚',
|
|
'Onboarding': '🎯',
|
|
'First Use': '🚀',
|
|
'Active': '⚡',
|
|
'Support': '💡',
|
|
'Purchase': '💳'
|
|
};
|
|
return icons[section] || '📋';
|
|
};
|
|
|
|
// Get phase status based on overall item status
|
|
const getPhaseStatus = (itemStatus: string, phase: 'scope' | 'design' | 'code'): 'built' | 'in_progress' | 'missing' => {
|
|
if (itemStatus === 'built') return 'built';
|
|
if (itemStatus === 'missing') return 'missing';
|
|
|
|
// If in_progress, show progression through phases
|
|
if (phase === 'scope') return 'built';
|
|
if (phase === 'design') return 'in_progress';
|
|
return 'missing';
|
|
};
|
|
|
|
// Render status badge
|
|
const renderStatusBadge = (status: 'built' | 'in_progress' | 'missing') => {
|
|
if (status === 'built') {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-green-100 text-green-800 text-xs font-medium">
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
Done
|
|
</span>
|
|
);
|
|
}
|
|
if (status === 'in_progress') {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-blue-100 text-blue-800 text-xs font-medium">
|
|
<Clock className="h-3 w-3" />
|
|
Started
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-gray-100 text-gray-800 text-xs font-medium">
|
|
<Circle className="h-3 w-3" />
|
|
To-do
|
|
</span>
|
|
);
|
|
};
|
|
|
|
const loadTimelineData = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch(`/api/projects/${projectId}/timeline-view`);
|
|
const result = await response.json();
|
|
|
|
// Check if the response is an error
|
|
if (result.error) {
|
|
console.error('API Error:', result.error, result.details);
|
|
alert(`Failed to load timeline: ${result.details || result.error}`);
|
|
return;
|
|
}
|
|
|
|
setData(result);
|
|
} catch (error) {
|
|
console.error('Error loading timeline:', error);
|
|
alert('Failed to load timeline data. Check console for details.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
loadTimelineData();
|
|
}, [loadTimelineData]);
|
|
|
|
const regeneratePlan = async () => {
|
|
if (!confirm('Regenerate the plan? This will analyze your project and create a fresh MVP checklist.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setRegenerating(true);
|
|
const response = await fetch(`/api/projects/${projectId}/mvp-checklist`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to regenerate plan');
|
|
}
|
|
|
|
// Reload the timeline data
|
|
await loadTimelineData();
|
|
} catch (error) {
|
|
console.error('Error regenerating plan:', error);
|
|
alert('Failed to regenerate plan. Check console for details.');
|
|
} finally {
|
|
setRegenerating(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!data) {
|
|
return <div className="p-8 text-center text-muted-foreground">No timeline data available</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="relative h-full w-full bg-background overflow-hidden flex">
|
|
{/* Left Sidebar */}
|
|
<CollapsibleSidebar>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold mb-2">Quick Stats</h3>
|
|
<div className="space-y-2 text-xs">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Total Items</span>
|
|
<span className="font-medium">{data.summary.totalWorkItems}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Built</span>
|
|
<span className="font-medium text-green-600">{data.summary.built}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">In Progress</span>
|
|
<span className="font-medium text-blue-600">{data.summary.withActivity - data.summary.built}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">To Build</span>
|
|
<span className="font-medium text-gray-600">{data.summary.missing}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CollapsibleSidebar>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1 flex flex-col p-4 space-y-3 overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold">MVP Checklist</h1>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
{data.summary.built} of {data.summary.totalWorkItems} pages built •
|
|
{data.summary.withActivity} with development activity
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-3 items-center">
|
|
{/* View Mode Switcher */}
|
|
<div className="flex items-center border rounded-lg p-1">
|
|
<Button
|
|
variant={viewMode === 'touchpoints' ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setViewMode('touchpoints')}
|
|
className="gap-2 h-7"
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
Touchpoints
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'technical' ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setViewMode('technical')}
|
|
className="gap-2 h-7"
|
|
>
|
|
<Cog className="h-4 w-4" />
|
|
Technical
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'journey' ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setViewMode('journey')}
|
|
className="gap-2 h-7"
|
|
>
|
|
<GitBranch className="h-4 w-4" />
|
|
Journey
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Regenerate Button */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={regeneratePlan}
|
|
disabled={regenerating}
|
|
className="gap-2"
|
|
>
|
|
{regenerating ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Regenerating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<RefreshCw className="h-4 w-4" />
|
|
Regenerate Plan
|
|
</>
|
|
)}
|
|
</Button>
|
|
|
|
{/* Summary Stats */}
|
|
<div className="text-xs px-3 py-1 bg-green-100 text-green-800 rounded">
|
|
✅ {data.summary.built} Built
|
|
</div>
|
|
<div className="text-xs px-3 py-1 bg-gray-100 text-gray-800 rounded">
|
|
⏳ {data.summary.missing} To Build
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Touchpoints View - What users see and engage with */}
|
|
{viewMode === 'touchpoints' && (
|
|
<Card className="flex-1 overflow-hidden flex flex-col p-0">
|
|
<div className="p-4 border-b bg-muted/30">
|
|
<p className="text-sm text-muted-foreground">Everything users see and engage with - screens, features, social posts, blogs, invites, and all customer-facing elements.</p>
|
|
</div>
|
|
<div className="overflow-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-muted/50 border-b sticky top-0">
|
|
<tr>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Type</th>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Touchpoint</th>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Scope</th>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Design</th>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Code</th>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Assigned</th>
|
|
<th className="text-center px-4 py-3 text-sm font-semibold">Sessions</th>
|
|
<th className="text-center px-4 py-3 text-sm font-semibold">Commits</th>
|
|
<th className="text-right px-4 py-3 text-sm font-semibold">Cost</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{data?.workItems.filter(item => isTouchpoint(item)).map((item, index) => (
|
|
<tr
|
|
key={item.id}
|
|
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
|
onClick={() => {
|
|
setExpandedItems(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(item.id)) {
|
|
newSet.delete(item.id);
|
|
} else {
|
|
newSet.add(item.id);
|
|
}
|
|
return newSet;
|
|
});
|
|
}}
|
|
>
|
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
|
{getWorkItemType(item)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
{item.status === 'built' ? (
|
|
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
|
|
) : item.status === 'in_progress' ? (
|
|
<Clock className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
|
) : (
|
|
<Circle className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
|
)}
|
|
<div>
|
|
<div className="text-sm font-medium">{item.title}</div>
|
|
{expandedItems.has(item.id) && (
|
|
<div className="mt-2 space-y-1">
|
|
{item.requirements.map((req) => (
|
|
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground ml-6">
|
|
{req.status === 'built' ? (
|
|
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
|
) : (
|
|
<Circle className="h-3 w-3 text-gray-400" />
|
|
)}
|
|
<span>{req.text}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-sm text-muted-foreground">
|
|
{item.assigned || data?.projectCreator || 'You'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-center">
|
|
<span className={item.sessionsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
|
{item.sessionsCount}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-center">
|
|
<span className={item.commitsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
|
{item.commitsCount}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-right text-muted-foreground">
|
|
{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Journey View - Customer lifecycle stages */}
|
|
{viewMode === 'journey' && (
|
|
<Card className="flex-1 overflow-auto p-0">
|
|
<div className="p-4 border-b bg-muted/30">
|
|
<p className="text-sm text-muted-foreground">Customer lifecycle journey from discovery to purchase - organizing all touchpoints and technical components by user stage.</p>
|
|
</div>
|
|
<div className="divide-y">
|
|
{/* Journey Sections - Customer Lifecycle */}
|
|
{['Discovery', 'Research', 'Onboarding', 'First Use', 'Active', 'Support', 'Purchase'].map(sectionName => {
|
|
const sectionItems = data.workItems.filter(item => getJourneySection(item) === sectionName);
|
|
if (sectionItems.length === 0) return null;
|
|
|
|
const sectionStats = {
|
|
done: sectionItems.filter(i => i.status === 'built').length,
|
|
started: sectionItems.filter(i => i.status === 'in_progress').length,
|
|
todo: sectionItems.filter(i => i.status === 'missing').length,
|
|
total: sectionItems.length
|
|
};
|
|
|
|
const isCollapsed = collapsedJourneySections.has(sectionName);
|
|
|
|
return (
|
|
<div key={sectionName}>
|
|
{/* Section Header */}
|
|
<div
|
|
className="bg-muted/30 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors flex items-center justify-between sticky top-0 z-10"
|
|
onClick={() => toggleJourneySection(sectionName)}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{isCollapsed ? (
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<span className="text-lg">{getJourneySectionIcon(sectionName)}</span>
|
|
<h3 className="font-semibold text-base">{sectionName}</h3>
|
|
<span className="text-xs text-muted-foreground">
|
|
{sectionStats.done}/{sectionStats.total} complete
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2 text-xs">
|
|
{sectionStats.done > 0 && (
|
|
<span className="px-2 py-1 bg-green-100 text-green-800 rounded">
|
|
{sectionStats.done} done
|
|
</span>
|
|
)}
|
|
{sectionStats.started > 0 && (
|
|
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded">
|
|
{sectionStats.started} started
|
|
</span>
|
|
)}
|
|
{sectionStats.todo > 0 && (
|
|
<span className="px-2 py-1 bg-gray-100 text-gray-800 rounded">
|
|
{sectionStats.todo} to-do
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section Items */}
|
|
{!isCollapsed && (
|
|
<div className="divide-y">
|
|
{sectionItems.map(item => (
|
|
<div key={item.id} className="px-4 py-3 hover:bg-accent/30 transition-colors">
|
|
<div
|
|
className="flex items-start justify-between cursor-pointer"
|
|
onClick={() => {
|
|
setExpandedItems(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(item.id)) {
|
|
newSet.delete(item.id);
|
|
} else {
|
|
newSet.add(item.id);
|
|
}
|
|
return newSet;
|
|
});
|
|
}}
|
|
>
|
|
<div className="flex items-start gap-3 flex-1">
|
|
{/* Status Icon */}
|
|
{item.status === 'built' ? (
|
|
<CheckCircle2 className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
|
) : item.status === 'in_progress' ? (
|
|
<Clock className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
|
) : (
|
|
<Circle className="h-5 w-5 text-gray-400 flex-shrink-0 mt-0.5" />
|
|
)}
|
|
|
|
<div className="flex-1">
|
|
{/* Title and Type */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">{item.title}</span>
|
|
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-700 rounded">
|
|
{getWorkItemType(item)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Phase Status */}
|
|
<div className="flex gap-2 mt-2">
|
|
<div className="text-xs">
|
|
<span className="text-muted-foreground">Spec:</span>{' '}
|
|
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
|
|
</div>
|
|
<div className="text-xs">
|
|
<span className="text-muted-foreground">Design:</span>{' '}
|
|
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
|
|
</div>
|
|
<div className="text-xs">
|
|
<span className="text-muted-foreground">Code:</span>{' '}
|
|
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Requirements */}
|
|
{expandedItems.has(item.id) && (
|
|
<div className="mt-3 space-y-1 pl-4 border-l-2 border-gray-200">
|
|
<p className="text-xs font-semibold text-muted-foreground mb-2">Requirements:</p>
|
|
{item.requirements.map((req) => (
|
|
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
{req.status === 'built' ? (
|
|
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
|
) : (
|
|
<Circle className="h-3 w-3 text-gray-400" />
|
|
)}
|
|
<span>{req.text}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Side Stats */}
|
|
<div className="flex items-start gap-4 text-xs text-muted-foreground">
|
|
<div className="text-center">
|
|
<div className="font-medium">Sessions</div>
|
|
<div className={item.sessionsCount > 0 ? 'text-blue-600 font-bold' : ''}>{item.sessionsCount}</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="font-medium">Commits</div>
|
|
<div className={item.commitsCount > 0 ? 'text-blue-600 font-bold' : ''}>{item.commitsCount}</div>
|
|
</div>
|
|
<div className="text-center min-w-[60px]">
|
|
<div className="font-medium">Cost</div>
|
|
<div>{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Technical View - Infrastructure that powers everything */}
|
|
{viewMode === 'technical' && (
|
|
<Card className="flex-1 overflow-hidden flex flex-col p-0">
|
|
<div className="p-4 border-b bg-muted/30">
|
|
<p className="text-sm text-muted-foreground">Technical infrastructure that powers the product - APIs, backend services, authentication, and system integrations.</p>
|
|
</div>
|
|
<div className="overflow-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-muted/50 border-b sticky top-0">
|
|
<tr>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Type</th>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Technical Component</th>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Scope</th>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Design</th>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Code</th>
|
|
<th className="text-left px-4 py-3 text-sm font-semibold">Assigned</th>
|
|
<th className="text-center px-4 py-3 text-sm font-semibold">Sessions</th>
|
|
<th className="text-center px-4 py-3 text-sm font-semibold">Commits</th>
|
|
<th className="text-right px-4 py-3 text-sm font-semibold">Cost</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{data?.workItems.filter(item => isTechnical(item)).map((item, index) => (
|
|
<tr
|
|
key={item.id}
|
|
className="hover:bg-accent/50 cursor-pointer transition-colors"
|
|
onClick={() => {
|
|
setExpandedItems(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(item.id)) {
|
|
newSet.delete(item.id);
|
|
} else {
|
|
newSet.add(item.id);
|
|
}
|
|
return newSet;
|
|
});
|
|
}}
|
|
>
|
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
|
{getWorkItemType(item)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
{item.status === 'built' ? (
|
|
<CheckCircle2 className="h-4 w-4 text-green-600 flex-shrink-0" />
|
|
) : item.status === 'in_progress' ? (
|
|
<Clock className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
|
) : (
|
|
<Circle className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
|
)}
|
|
<div>
|
|
<div className="text-sm font-medium">{item.title}</div>
|
|
{expandedItems.has(item.id) && (
|
|
<div className="mt-2 space-y-1">
|
|
{item.requirements.map((req) => (
|
|
<div key={req.id} className="flex items-center gap-2 text-xs text-muted-foreground ml-6">
|
|
{req.status === 'built' ? (
|
|
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
|
) : (
|
|
<Circle className="h-3 w-3 text-gray-400" />
|
|
)}
|
|
<span>{req.text}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{renderStatusBadge(getPhaseStatus(item.status, 'scope'))}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{renderStatusBadge(getPhaseStatus(item.status, 'design'))}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{renderStatusBadge(getPhaseStatus(item.status, 'code'))}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-sm text-muted-foreground">
|
|
{item.assigned || data?.projectCreator || 'You'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-center">
|
|
<span className={item.sessionsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
|
{item.sessionsCount}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-center">
|
|
<span className={item.commitsCount > 0 ? 'text-blue-600 font-medium' : 'text-gray-400'}>
|
|
{item.commitsCount}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-right text-muted-foreground">
|
|
{item.estimatedCost ? `$${item.estimatedCost.toFixed(2)}` : '-'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
{/* End Main Content */}
|
|
</div>
|
|
);
|
|
}
|