2
This commit is contained in:
387
frontend/src/components/dashboard/ThreeWidgetFooter.tsx
Normal file
387
frontend/src/components/dashboard/ThreeWidgetFooter.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* ThreeWidgetFooter - 3-Column Layout for Table Page Footers
|
||||
*
|
||||
* Design from Section 3 of COMPREHENSIVE-AUDIT-REPORT.md:
|
||||
* ┌─────────────────────────────────────────────────────────────────────────────────────┐
|
||||
* │ WIDGET 1: PAGE METRICS │ WIDGET 2: MODULE STATS │ WIDGET 3: COMPLETION │
|
||||
* │ (Current Page Progress) │ (Full Module Overview) │ (Both Modules Stats) │
|
||||
* │ ~33.3% width │ ~33.3% width │ ~33.3% width │
|
||||
* └─────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* STYLING: Uses CSS tokens from styles/tokens.css:
|
||||
* - --color-primary: Brand blue for primary actions/bars
|
||||
* - --color-success: Green for success states
|
||||
* - --color-warning: Amber for warnings
|
||||
* - --color-purple: Purple accent
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card } from '../ui/card/Card';
|
||||
import { LightBulbIcon, ChevronRightIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
// ============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
/** Submodule color type - matches headerMetrics accentColor */
|
||||
export type SubmoduleColor = 'blue' | 'green' | 'amber' | 'purple';
|
||||
|
||||
/** Widget 1: Page Progress - metrics in 2x2 grid + progress bar + hint */
|
||||
export interface PageProgressWidget {
|
||||
title: string;
|
||||
metrics: Array<{ label: string; value: string | number; percentage?: string }>;
|
||||
progress: { value: number; label: string; color?: SubmoduleColor };
|
||||
hint?: string;
|
||||
/** The submodule's accent color - progress bar uses this */
|
||||
submoduleColor?: SubmoduleColor;
|
||||
}
|
||||
|
||||
/** Widget 2: Module Stats - Pipeline flow with arrows and progress bars */
|
||||
export interface ModulePipelineRow {
|
||||
fromLabel: string;
|
||||
fromValue: number;
|
||||
fromHref?: string;
|
||||
actionLabel: string;
|
||||
toLabel: string;
|
||||
toValue: number;
|
||||
toHref?: string;
|
||||
progress: number; // 0-100
|
||||
/** Color for this pipeline row's progress bar */
|
||||
color?: SubmoduleColor;
|
||||
}
|
||||
|
||||
export interface ModuleStatsWidget {
|
||||
title: string;
|
||||
pipeline: ModulePipelineRow[];
|
||||
links: Array<{ label: string; href: string }>;
|
||||
}
|
||||
|
||||
/** Widget 3: Completion - Tree structure with bars for both modules */
|
||||
export interface CompletionItem {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: SubmoduleColor;
|
||||
}
|
||||
|
||||
export interface CompletionWidget {
|
||||
title: string;
|
||||
plannerItems: CompletionItem[];
|
||||
writerItems: CompletionItem[];
|
||||
creditsUsed?: number;
|
||||
operationsCount?: number;
|
||||
analyticsHref?: string;
|
||||
}
|
||||
|
||||
/** Main component props */
|
||||
export interface ThreeWidgetFooterProps {
|
||||
pageProgress: PageProgressWidget;
|
||||
moduleStats: ModuleStatsWidget;
|
||||
completion: CompletionWidget;
|
||||
submoduleColor?: SubmoduleColor;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COLOR UTILITIES
|
||||
// ============================================================================
|
||||
|
||||
const getProgressBarStyle = (color: SubmoduleColor = 'blue'): React.CSSProperties => {
|
||||
const colorMap: Record<SubmoduleColor, string> = {
|
||||
blue: 'var(--color-primary)',
|
||||
green: 'var(--color-success)',
|
||||
amber: 'var(--color-warning)',
|
||||
purple: 'var(--color-purple)',
|
||||
};
|
||||
return { backgroundColor: colorMap[color] };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// WIDGET 1: PAGE PROGRESS
|
||||
// ============================================================================
|
||||
|
||||
function PageProgressCard({ widget, submoduleColor = 'blue' }: { widget: PageProgressWidget; submoduleColor?: SubmoduleColor }) {
|
||||
const progressColor = widget.submoduleColor || widget.progress.color || submoduleColor;
|
||||
|
||||
return (
|
||||
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{widget.title}
|
||||
</h3>
|
||||
|
||||
{/* 2x2 Metrics Grid */}
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 mb-5">
|
||||
{widget.metrics.slice(0, 4).map((metric, idx) => (
|
||||
<div key={idx} className="flex items-baseline justify-between">
|
||||
<span className="text-sm text-[color:var(--color-text-dim)] dark:text-gray-400">{metric.label}</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-lg font-bold text-[color:var(--color-text)] dark:text-white tabular-nums">
|
||||
{typeof metric.value === 'number' ? metric.value.toLocaleString() : metric.value}
|
||||
</span>
|
||||
{metric.percentage && (
|
||||
<span className="text-xs text-[color:var(--color-text-dim)] dark:text-gray-400">({metric.percentage})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
...getProgressBarStyle(progressColor),
|
||||
width: `${Math.min(100, Math.max(0, widget.progress.value))}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{widget.progress.label}</span>
|
||||
<span className="text-sm font-bold text-[color:var(--color-text)] dark:text-white">{widget.progress.value}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint with icon */}
|
||||
{widget.hint && (
|
||||
<div className="flex items-start gap-2 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
|
||||
<LightBulbIcon className="w-5 h-5 flex-shrink-0 mt-0.5 text-warning-500" />
|
||||
<span className="text-sm font-medium text-brand-500">{widget.hint}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WIDGET 2: MODULE STATS
|
||||
// ============================================================================
|
||||
|
||||
function ModuleStatsCard({ widget }: { widget: ModuleStatsWidget }) {
|
||||
return (
|
||||
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{widget.title}
|
||||
</h3>
|
||||
|
||||
{/* Pipeline Rows */}
|
||||
<div className="space-y-4 mb-4">
|
||||
{widget.pipeline.map((row, idx) => (
|
||||
<div key={idx}>
|
||||
{/* Row header: FromLabel Value ► ToLabel Value */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{/* From side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{row.fromHref ? (
|
||||
<Link
|
||||
to={row.fromHref}
|
||||
className="text-sm font-medium hover:underline text-brand-500"
|
||||
>
|
||||
{row.fromLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{row.fromLabel}</span>
|
||||
)}
|
||||
<span className="text-lg font-bold tabular-nums text-brand-500">
|
||||
{row.fromValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Arrow icon */}
|
||||
<ChevronRightIcon
|
||||
className="w-6 h-6 flex-shrink-0 mx-2 text-brand-500"
|
||||
/>
|
||||
|
||||
{/* To side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{row.toHref ? (
|
||||
<Link
|
||||
to={row.toHref}
|
||||
className="text-sm font-medium hover:underline text-brand-500"
|
||||
>
|
||||
{row.toLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-[color:var(--color-text)] dark:text-gray-300">{row.toLabel}</span>
|
||||
)}
|
||||
<span className="text-lg font-bold text-[color:var(--color-text)] dark:text-white tabular-nums">
|
||||
{row.toValue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
...getProgressBarStyle(row.color || 'blue'),
|
||||
width: `${Math.min(100, Math.max(0, row.progress))}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="flex flex-wrap gap-3 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
|
||||
{widget.links.map((link, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
to={link.href}
|
||||
className="text-sm font-medium hover:underline flex items-center gap-1 text-brand-500"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
<span>{link.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WIDGET 3: COMPLETION
|
||||
// ============================================================================
|
||||
|
||||
function CompletionCard({ widget }: { widget: CompletionWidget }) {
|
||||
// Calculate max for proportional bars (across both columns)
|
||||
const allValues = [...widget.plannerItems, ...widget.writerItems].map(i => i.value);
|
||||
const maxValue = Math.max(...allValues, 1);
|
||||
|
||||
const renderItem = (item: CompletionItem, isLast: boolean) => {
|
||||
const barWidth = (item.value / maxValue) * 100;
|
||||
const prefix = isLast ? '└─' : '├─';
|
||||
const color = item.color || 'blue';
|
||||
|
||||
return (
|
||||
<div key={item.label} className="flex items-center gap-2 py-1">
|
||||
{/* Tree prefix */}
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-500 font-mono text-xs w-5 flex-shrink-0">{prefix}</span>
|
||||
|
||||
{/* Label */}
|
||||
<span className="text-sm text-[color:var(--color-text)] dark:text-gray-300 flex-1 truncate">{item.label}</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-16 h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden flex-shrink-0">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
...getProgressBarStyle(color),
|
||||
width: `${Math.min(100, barWidth)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<span className="text-sm font-bold text-[color:var(--color-text)] dark:text-white tabular-nums w-10 text-right flex-shrink-0">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-5 bg-white dark:bg-gray-900 border border-[color:var(--color-stroke)] dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<h3 className="text-sm font-semibold text-[color:var(--color-text)] dark:text-gray-200 uppercase tracking-wide mb-4">
|
||||
{widget.title}
|
||||
</h3>
|
||||
|
||||
{/* Two-column layout: Planner | Writer */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||||
{/* Planner Column */}
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wide mb-2 text-brand-500">
|
||||
Planner
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{widget.plannerItems.map((item, idx) =>
|
||||
renderItem(item, idx === widget.plannerItems.length - 1)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Writer Column */}
|
||||
<div>
|
||||
<div className="text-xs font-bold uppercase tracking-wide mb-2 text-success-500">
|
||||
Writer
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{widget.writerItems.map((item, idx) =>
|
||||
renderItem(item, idx === widget.writerItems.length - 1)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Stats - Credits Used & Operations */}
|
||||
{(widget.creditsUsed !== undefined || widget.operationsCount !== undefined) && (
|
||||
<div className="flex items-center gap-4 pt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800 text-sm">
|
||||
{widget.creditsUsed !== undefined && (
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Credits Used: <strong className="text-[color:var(--color-text)] dark:text-white font-bold">{widget.creditsUsed.toLocaleString()}</strong>
|
||||
</span>
|
||||
)}
|
||||
{widget.creditsUsed !== undefined && widget.operationsCount !== undefined && (
|
||||
<span className="text-[color:var(--color-stroke)] dark:text-gray-600">│</span>
|
||||
)}
|
||||
{widget.operationsCount !== undefined && (
|
||||
<span className="text-[color:var(--color-text-dim)] dark:text-gray-400">
|
||||
Operations: <strong className="text-[color:var(--color-text)] dark:text-white font-bold">{widget.operationsCount}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analytics Link */}
|
||||
{widget.analyticsHref && (
|
||||
<div className="pt-3 mt-3 border-t border-[color:var(--color-stroke)] dark:border-gray-800">
|
||||
<Link
|
||||
to={widget.analyticsHref}
|
||||
className="text-sm font-medium hover:underline flex items-center gap-1 text-brand-500"
|
||||
>
|
||||
View Full Analytics
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export default function ThreeWidgetFooter({
|
||||
pageProgress,
|
||||
moduleStats,
|
||||
completion,
|
||||
submoduleColor = 'blue',
|
||||
className = '',
|
||||
}: ThreeWidgetFooterProps) {
|
||||
return (
|
||||
<div className={`mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 ${className}`}>
|
||||
{/*
|
||||
* Widget widths adjusted per requirements:
|
||||
* - Widget 1 (Page Progress): 28.3% (reduced by 5%)
|
||||
* - Widget 2 (Module Stats): 28.3% (reduced by 5%)
|
||||
* - Widget 3 (Workflow Completion): 43.4% (increased by 10%)
|
||||
*/}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[28.3%_28.3%_43.4%] gap-4">
|
||||
<PageProgressCard widget={pageProgress} submoduleColor={submoduleColor} />
|
||||
<ModuleStatsCard widget={moduleStats} />
|
||||
<CompletionCard widget={completion} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Also export sub-components for flexibility
|
||||
export { PageProgressCard, ModuleStatsCard, CompletionCard };
|
||||
Reference in New Issue
Block a user