338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
/**
|
|
* Site Progress Widget - Stage 3
|
|
* Displays cluster-level completion bars for hub/supporting/attribute pages
|
|
*/
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Card } from '../ui/card';
|
|
import Badge from '../ui/badge/Badge';
|
|
// import { fetchSiteProgress, SiteProgress } from '../../services/api';
|
|
import { CheckCircleIcon, XCircleIcon, AlertCircleIcon, ArrowRightIcon } from 'lucide-react';
|
|
|
|
interface SiteProgressWidgetProps {
|
|
blueprintId: number;
|
|
siteId?: number;
|
|
}
|
|
|
|
export default function SiteProgressWidget({ blueprintId, siteId }: SiteProgressWidgetProps) {
|
|
const navigate = useNavigate();
|
|
const [progress, setProgress] = useState<SiteProgress | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [retryCount, setRetryCount] = useState(0);
|
|
|
|
useEffect(() => {
|
|
loadProgress();
|
|
}, [blueprintId]);
|
|
|
|
const loadProgress = async () => {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const data = await fetchSiteProgress(blueprintId);
|
|
setProgress(data);
|
|
setRetryCount(0); // Reset retry count on success
|
|
} catch (error: any) {
|
|
console.error('Failed to load site progress:', error);
|
|
setError(error.message || 'Failed to load site progress. Please try again.');
|
|
setProgress(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRetry = () => {
|
|
setRetryCount(prev => prev + 1);
|
|
loadProgress();
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card className="p-4">
|
|
<div className="text-center py-4">
|
|
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mb-2"></div>
|
|
<div className="text-gray-500 dark:text-gray-400">Loading progress...</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (error && !progress) {
|
|
return (
|
|
<Card className="p-4">
|
|
<div className="text-center py-4">
|
|
<AlertCircleIcon className="w-8 h-8 text-red-500 mx-auto mb-2" />
|
|
<div className="text-sm text-red-600 dark:text-red-400 mb-3">{error}</div>
|
|
<button
|
|
onClick={handleRetry}
|
|
disabled={retryCount >= 3}
|
|
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white rounded-lg text-sm font-medium transition-colors"
|
|
aria-label="Retry loading site progress"
|
|
>
|
|
{retryCount >= 3 ? 'Max retries reached' : 'Retry'}
|
|
</button>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (!progress) {
|
|
return (
|
|
<Card className="p-4">
|
|
<div className="text-center py-4 text-gray-500 dark:text-gray-400">
|
|
<AlertCircleIcon className="w-6 h-6 mx-auto mb-2 opacity-50" />
|
|
<p>No progress data available</p>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const clusterCoverage: NonNullable<SiteProgress['cluster_coverage']> =
|
|
progress.cluster_coverage ?? {
|
|
total_clusters: 0,
|
|
covered_clusters: 0,
|
|
details: [] as NonNullable<SiteProgress['cluster_coverage']>['details'],
|
|
};
|
|
|
|
const taxonomyCoverage: NonNullable<SiteProgress['taxonomy_coverage']> =
|
|
progress.taxonomy_coverage ?? {
|
|
total_taxonomies: 0,
|
|
defined_taxonomies: 0,
|
|
details: [] as NonNullable<SiteProgress['taxonomy_coverage']>['details'],
|
|
};
|
|
|
|
const validationFlags: NonNullable<SiteProgress['validation_flags']> =
|
|
progress.validation_flags ?? {
|
|
clusters_attached: false,
|
|
taxonomies_defined: false,
|
|
sitemap_generated: false,
|
|
all_pages_generated: false,
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case 'complete':
|
|
return 'success';
|
|
case 'blocked':
|
|
return 'error';
|
|
default:
|
|
return 'warning';
|
|
}
|
|
};
|
|
|
|
const getRoleColor = (role: string) => {
|
|
switch (role) {
|
|
case 'hub':
|
|
return 'primary';
|
|
case 'supporting':
|
|
return 'success';
|
|
case 'attribute':
|
|
return 'warning';
|
|
default:
|
|
return 'info';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white" id="site-progress-title">
|
|
Site Progress: {progress.blueprint_name}
|
|
</h3>
|
|
<Badge color={getStatusColor(progress.overall_status)} size="sm" aria-label={`Status: ${progress.overall_status || 'Unknown'}`}>
|
|
{progress.overall_status ? progress.overall_status.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()) : 'Unknown'}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Overall Stats */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{clusterCoverage.covered_clusters}/{clusterCoverage.total_clusters}
|
|
</div>
|
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">Clusters Covered</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{taxonomyCoverage.defined_taxonomies}/{taxonomyCoverage.total_taxonomies}
|
|
</div>
|
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">Taxonomies Defined</div>
|
|
</div>
|
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
{validationFlags.all_pages_generated ? '✓' : '✗'}
|
|
</div>
|
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">All Pages Generated</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cluster Progress */}
|
|
<div className="space-y-4" aria-labelledby="cluster-coverage-title">
|
|
<h4 id="cluster-coverage-title" className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
Cluster Coverage
|
|
</h4>
|
|
{clusterCoverage.details && clusterCoverage.details.length > 0 ? (
|
|
clusterCoverage.details.map((cluster) => {
|
|
const hubPages = cluster.hub_pages ?? 0;
|
|
const supportingPages = cluster.supporting_pages ?? 0;
|
|
const attributePages = cluster.attribute_pages ?? 0;
|
|
const contentCount = cluster.content_count ?? 0;
|
|
const totalPages = hubPages + supportingPages + attributePages;
|
|
const completionPercent = totalPages > 0 ? Math.min(100, (contentCount / totalPages) * 100) : 0;
|
|
|
|
return (
|
|
<div
|
|
key={cluster.cluster_id}
|
|
className={`p-4 rounded-lg border-2 ${
|
|
cluster.is_complete
|
|
? 'border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20'
|
|
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h5 className="font-medium text-gray-900 dark:text-white">
|
|
{cluster.cluster_name}
|
|
</h5>
|
|
<Badge color={getRoleColor(cluster.role)} size="sm" variant="light">
|
|
{cluster.role}
|
|
</Badge>
|
|
{cluster.is_complete ? (
|
|
<CheckCircleIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
|
|
) : (
|
|
<AlertCircleIcon className="w-4 h-4 text-amber-600 dark:text-amber-400" />
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-gray-600 dark:text-gray-400">
|
|
{contentCount} content / {totalPages} pages
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => navigate(`/planner/clusters/${cluster.cluster_id}`)}
|
|
className="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 transition-colors"
|
|
aria-label={`View cluster ${cluster.cluster_name}`}
|
|
>
|
|
View <ArrowRightIcon className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="mt-2">
|
|
<div className="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400 mb-1">
|
|
<span>Completion</span>
|
|
<span>{completionPercent.toFixed(0)}%</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
<div
|
|
className={`h-2 rounded-full ${
|
|
cluster.is_complete
|
|
? 'bg-green-500 dark:bg-green-400'
|
|
: 'bg-blue-500 dark:bg-blue-400'
|
|
}`}
|
|
style={{ width: `${completionPercent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Page Type Breakdown */}
|
|
<div className="grid grid-cols-3 gap-2 mt-3 text-xs">
|
|
<div className="text-center p-2 bg-white dark:bg-gray-700 rounded">
|
|
<div className="font-medium text-gray-900 dark:text-white">{hubPages}</div>
|
|
<div className="text-gray-600 dark:text-gray-400">Hub</div>
|
|
</div>
|
|
<div className="text-center p-2 bg-white dark:bg-gray-700 rounded">
|
|
<div className="font-medium text-gray-900 dark:text-white">{supportingPages}</div>
|
|
<div className="text-gray-600 dark:text-gray-400">Supporting</div>
|
|
</div>
|
|
<div className="text-center p-2 bg-white dark:bg-gray-700 rounded">
|
|
<div className="font-medium text-gray-900 dark:text-white">{attributePages}</div>
|
|
<div className="text-gray-600 dark:text-gray-400">Attribute</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Validation Messages */}
|
|
{cluster.validation_messages && cluster.validation_messages.length > 0 && (
|
|
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">
|
|
Issues:
|
|
</div>
|
|
<ul className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
|
{cluster.validation_messages.map((msg, idx) => (
|
|
<li key={idx} className="flex items-start gap-1">
|
|
<XCircleIcon className="w-3 h-3 text-red-500 mt-0.5 flex-shrink-0" />
|
|
<span>{msg}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
|
|
<AlertCircleIcon className="w-6 h-6 mx-auto mb-2 opacity-50" />
|
|
<p className="text-sm">No clusters found. Attach clusters to get started.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Validation Flags Summary */}
|
|
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700" aria-labelledby="validation-status-title">
|
|
<h4 id="validation-status-title" className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
|
Validation Status
|
|
</h4>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div className="flex items-center gap-2">
|
|
{validationFlags.clusters_attached ? (
|
|
<CheckCircleIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
|
|
) : (
|
|
<XCircleIcon className="w-4 h-4 text-red-600 dark:text-red-400" />
|
|
)}
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">Clusters Attached</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{validationFlags.taxonomies_defined ? (
|
|
<CheckCircleIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
|
|
) : (
|
|
<XCircleIcon className="w-4 h-4 text-red-600 dark:text-red-400" />
|
|
)}
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">Taxonomies Defined</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{validationFlags.sitemap_generated ? (
|
|
<CheckCircleIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
|
|
) : (
|
|
<XCircleIcon className="w-4 h-4 text-red-600 dark:text-red-400" />
|
|
)}
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">Sitemap Generated</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{validationFlags.all_pages_generated ? (
|
|
<CheckCircleIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
|
|
) : (
|
|
<XCircleIcon className="w-4 h-4 text-red-600 dark:text-red-400" />
|
|
)}
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">All Pages Generated</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{/* Error banner if data loaded but has errors */}
|
|
{error && progress && (
|
|
<div className="mt-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
|
<div className="flex items-start gap-2">
|
|
<AlertCircleIcon className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
|
|
<div className="text-xs text-yellow-800 dark:text-yellow-300">
|
|
Some data may be outdated. <button onClick={handleRetry} className="underline font-medium">Refresh</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|
|
|