feat: Add interactive 3D tab to Room Detail and refactor styling
This commit is contained in:
parent
aa8e5d226f
commit
477c076d03
2 changed files with 126 additions and 36 deletions
74
frontend/src/components/facility3d/Room3DViewer.tsx
Normal file
74
frontend/src/components/facility3d/Room3DViewer.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { PlantPosition } from './types';
|
||||||
|
// ...
|
||||||
|
interface Room3DViewerProps {
|
||||||
|
room: any;
|
||||||
|
visMode?: 'STANDARD' | 'HEALTH' | 'YIELD' | 'TEMP' | 'HUMIDITY';
|
||||||
|
onPlantClick?: (plant: PlantPosition) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Room3DViewer({ room, visMode = 'STANDARD', onPlantClick }: Room3DViewerProps) {
|
||||||
|
// Transform API room data to Room3D structure if needed
|
||||||
|
const room3DData: Room3D = useMemo(() => {
|
||||||
|
// If it already looks like a Room3D object (has racks/sections), use it.
|
||||||
|
// Otherwise, construct a mock layout for visualization.
|
||||||
|
if (room.racks || room.sections) {
|
||||||
|
return {
|
||||||
|
...room,
|
||||||
|
// Ensure dimensions exist
|
||||||
|
width: room.width || 20,
|
||||||
|
height: room.height || 30,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
sections: room.sections || room.racks || [], // Map racks/sections to standard field
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Create a mock layout based on capacity
|
||||||
|
return {
|
||||||
|
id: room.id,
|
||||||
|
name: room.name,
|
||||||
|
type: room.type,
|
||||||
|
width: 20,
|
||||||
|
height: 30,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
sections: [], // Empty for now, or could generatively fill
|
||||||
|
};
|
||||||
|
}, [room]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[600px] bg-gradient-to-br from-slate-900 to-slate-950 rounded-2xl overflow-hidden border border-[var(--color-border-subtle)] relative">
|
||||||
|
<Canvas
|
||||||
|
shadows
|
||||||
|
camera={{ position: [0, 40, 30], fov: 45 }}
|
||||||
|
className="w-full h-full"
|
||||||
|
>
|
||||||
|
<ambientLight intensity={0.5} />
|
||||||
|
<directionalLight position={[10, 20, 10]} intensity={1} castShadow />
|
||||||
|
|
||||||
|
<group position={[0, 0, 0]}>
|
||||||
|
<RoomObject
|
||||||
|
room={room3DData}
|
||||||
|
visMode={visMode}
|
||||||
|
onPlantClick={onPlantClick || (() => { })}
|
||||||
|
highlightedTags={[]}
|
||||||
|
dimMode={false}
|
||||||
|
hierarchy={{
|
||||||
|
facility: 'Main',
|
||||||
|
building: 'A',
|
||||||
|
floor: '1',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<ContactShadows position={[0, -0.01, 0]} opacity={0.4} scale={40} blur={2} far={10} />
|
||||||
|
<Environment preset="city" />
|
||||||
|
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2.2} />
|
||||||
|
</Canvas>
|
||||||
|
|
||||||
|
<div className="absolute top-4 right-4 bg-black/60 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/10 text-xs font-medium text-white/80 pointer-events-none">
|
||||||
|
Interactive 3D View
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Thermometer, Droplets, Wind, Zap, Sun,
|
ArrowLeft, Thermometer, Droplets, Wind, Zap,
|
||||||
Sprout, Calendar, Edit2, Layers, Clock, CheckCircle2,
|
Sprout, Calendar, Edit2, Layers, Clock, CheckCircle2,
|
||||||
Activity, History, ClipboardList, Filter, MoreHorizontal, Plus
|
Activity, ClipboardList, Plus, Box
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '../lib/api';
|
import api from '../lib/api';
|
||||||
import { Card } from '../components/ui/card';
|
import { Card } from '../components/ui/card';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { Room3DViewer } from '../components/facility3d/Room3DViewer';
|
||||||
|
|
||||||
// Mock sensor data generator for Sparklines
|
// Mock sensor data generator for Sparklines
|
||||||
function generateMockData(count: number, min: number, max: number) {
|
function generateMockData(count: number, min: number, max: number) {
|
||||||
|
|
@ -22,7 +23,7 @@ export default function RoomDetailPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [room, setRoom] = useState<any>(null);
|
const [room, setRoom] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState<'batches' | 'tasks' | 'environment'>('batches');
|
const [activeTab, setActiveTab] = useState<'batches' | 'tasks' | 'environment' | '3d'>('batches');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
|
|
@ -37,7 +38,7 @@ export default function RoomDetailPage() {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[50vh]">
|
<div className="flex items-center justify-center min-h-[50vh]">
|
||||||
<div className="animate-spin w-8 h-8 border-4 border-emerald-500 border-t-transparent rounded-full" />
|
<div className="animate-spin w-8 h-8 border-4 border-[var(--color-primary)] border-t-transparent rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -58,47 +59,47 @@ export default function RoomDetailPage() {
|
||||||
<div className="space-y-4 flex-1">
|
<div className="space-y-4 flex-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/dashboard')}
|
onClick={() => navigate('/dashboard')}
|
||||||
className="flex items-center gap-2 text-xs font-bold text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] uppercase tracking-widest transition-colors"
|
className="flex items-center gap-2 text-xs font-semibold text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={14} /> Back to Overview
|
<ArrowLeft size={14} /> Back to Overview
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-[var(--color-text-primary)] ">
|
<h1 className="text-3xl font-bold tracking-tight text-[var(--color-text-primary)]">
|
||||||
{room.name?.replace('[DEMO] ', '')}
|
{room.name?.replace('[DEMO] ', '')}
|
||||||
</h1>
|
</h1>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wide border shadow-sm",
|
"px-3 py-1 rounded-full text-xs font-semibold border shadow-sm",
|
||||||
room.type === 'FLOWER'
|
room.type === 'FLOWER'
|
||||||
? "bg-purple-500/10 border-purple-500/20 text-purple-500"
|
? "bg-purple-500/10 border-purple-500/20 text-purple-600 dark:text-purple-400"
|
||||||
: "bg-[var(--color-primary)]/10 border-emerald-500/20 text-[var(--color-primary)]"
|
: "bg-[var(--color-primary)]/10 border-[var(--color-primary)]/20 text-[var(--color-primary)]"
|
||||||
)}>
|
)}>
|
||||||
{room.type} Phase
|
{room.type} Phase
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6 mt-4 text-xs font-medium text-[var(--color-text-tertiary)]">
|
<div className="flex items-center gap-6 mt-4 text-xs font-medium text-[var(--color-text-tertiary)]">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layers size={14} className="text-[var(--color-text-tertiary)]" />
|
<Layers size={14} />
|
||||||
<span>{room.sqft?.toLocaleString()} SQFT</span>
|
<span>{room.sqft?.toLocaleString()} SQFT</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sprout size={14} className="text-[var(--color-text-tertiary)]" />
|
<Sprout size={14} />
|
||||||
<span>{room.capacity || 0} Plants max</span>
|
<span>{room.capacity || 0} Plants max</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-[var(--color-primary)] animate-pulse" />
|
<div className="w-2 h-2 rounded-full bg-[var(--color-primary)] animate-pulse" />
|
||||||
<span className="text-[var(--color-primary)]">System Nominal</span>
|
<span className="text-[var(--color-primary)] font-medium">System Nominal</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button className="p-2.5 rounded-xl border border-[var(--color-border-subtle)] hover:bg-slate-100 dark:hover:bg-slate-800 transition-all text-[var(--color-text-tertiary)]">
|
<button className="p-2.5 rounded-xl border border-[var(--color-border-subtle)] hover:bg-[var(--color-bg-tertiary)] transition-all text-[var(--color-text-secondary)]">
|
||||||
<Edit2 size={18} />
|
<Edit2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white px-6 py-2.5 rounded-xl font-bold text-sm shadow-xl shadow-emerald-500/20 transition-all uppercase tracking-widest">
|
<button className="flex items-center gap-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white px-6 py-2.5 rounded-xl font-bold text-sm shadow-md transition-all">
|
||||||
<Plus size={18} /> Log Action
|
<Plus size={18} /> Log Action
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -112,6 +113,7 @@ export default function RoomDetailPage() {
|
||||||
value={room.targetTemp || 75.5}
|
value={room.targetTemp || 75.5}
|
||||||
unit="°F"
|
unit="°F"
|
||||||
color="text-[var(--color-error)]"
|
color="text-[var(--color-error)]"
|
||||||
|
fill="bg-[var(--color-error)]"
|
||||||
data={sensorData.temp}
|
data={sensorData.temp}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
|
|
@ -120,6 +122,7 @@ export default function RoomDetailPage() {
|
||||||
value={room.targetHumidity || 55}
|
value={room.targetHumidity || 55}
|
||||||
unit="%"
|
unit="%"
|
||||||
color="text-[var(--color-accent)]"
|
color="text-[var(--color-accent)]"
|
||||||
|
fill="bg-[var(--color-accent)]"
|
||||||
data={sensorData.humidity}
|
data={sensorData.humidity}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
|
|
@ -128,6 +131,7 @@ export default function RoomDetailPage() {
|
||||||
value={1.15}
|
value={1.15}
|
||||||
unit="kPa"
|
unit="kPa"
|
||||||
color="text-[var(--color-primary)]"
|
color="text-[var(--color-primary)]"
|
||||||
|
fill="bg-[var(--color-primary)]"
|
||||||
data={sensorData.vpd}
|
data={sensorData.vpd}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
|
|
@ -136,13 +140,14 @@ export default function RoomDetailPage() {
|
||||||
value={1200}
|
value={1200}
|
||||||
unit="ppm"
|
unit="ppm"
|
||||||
color="text-[var(--color-warning)]"
|
color="text-[var(--color-warning)]"
|
||||||
|
fill="bg-[var(--color-warning)]"
|
||||||
data={sensorData.co2}
|
data={sensorData.co2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Tabs */}
|
{/* Content Tabs */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-8 border-b border-[var(--color-border-subtle)]">
|
<div className="flex overflow-x-auto items-center gap-8 border-b border-[var(--color-border-subtle)] pb-1">
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === 'batches'}
|
active={activeTab === 'batches'}
|
||||||
icon={Sprout}
|
icon={Sprout}
|
||||||
|
|
@ -150,6 +155,12 @@ export default function RoomDetailPage() {
|
||||||
count={room.batches?.length || 0}
|
count={room.batches?.length || 0}
|
||||||
onClick={() => setActiveTab('batches')}
|
onClick={() => setActiveTab('batches')}
|
||||||
/>
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === '3d'}
|
||||||
|
icon={Box}
|
||||||
|
label="3D View"
|
||||||
|
onClick={() => setActiveTab('3d')}
|
||||||
|
/>
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === 'tasks'}
|
active={activeTab === 'tasks'}
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
|
|
@ -166,48 +177,52 @@ export default function RoomDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-[400px]">
|
<div className="min-h-[400px]">
|
||||||
|
{activeTab === '3d' && (
|
||||||
|
<Room3DViewer room={room} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'batches' && (
|
{activeTab === 'batches' && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{room.batches?.map((batch: any) => (
|
{room.batches?.map((batch: any) => (
|
||||||
<Link key={batch.id} to={`/batches/${batch.id}`}>
|
<Link key={batch.id} to={`/batches/${batch.id}`}>
|
||||||
<div className="group p-5 bg-white bg-[var(--color-bg-elevated)] border border-[var(--color-border-subtle)] rounded-2xl hover:border-emerald-500/50 transition-all flex items-center justify-between">
|
<div className="group p-5 bg-[var(--color-bg-elevated)] border border-[var(--color-border-subtle)] rounded-2xl hover:border-[var(--color-primary)] transition-all flex items-center justify-between shadow-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-12 h-12 rounded-xl bg-[var(--color-bg-tertiary)] flex items-center justify-center border border-[var(--color-border-subtle)] group-hover:scale-110 transition-transform">
|
<div className="w-12 h-12 rounded-xl bg-[var(--color-bg-tertiary)] flex items-center justify-center border border-[var(--color-border-subtle)] group-hover:scale-105 transition-transform">
|
||||||
<Sprout size={20} className="text-[var(--color-primary)]" />
|
<Sprout size={20} className="text-[var(--color-primary)]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-base font-bold text-[var(--color-text-primary)]">{batch.name}</h4>
|
<h4 className="text-base font-bold text-[var(--color-text-primary)]">{batch.name}</h4>
|
||||||
<p className="text-xs text-[var(--color-text-tertiary)] font-medium mt-0.5">{batch.strain} • {batch.plantCount} plants</p>
|
<p className="text-xs text-[var(--color-text-tertiary)] font-medium mt-1">{batch.strain} • {batch.plantCount} plants</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="px-2 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)] text-[10px] font-medium border border-emerald-500/20 mb-2 inline-block">
|
<div className="px-2 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)] text-xs font-bold border border-[var(--color-primary)]/20 mb-2 inline-block">
|
||||||
{batch.stage}
|
{batch.stage}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)] font-bold uppercase tracking-tighter">
|
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-tertiary)] font-medium">
|
||||||
<Clock size={12} /> Day {Math.floor((Date.now() - new Date(batch.startDate).getTime()) / 86400000)}
|
<Clock size={12} /> Day {Math.floor((Date.now() - new Date(batch.startDate).getTime()) / 86400000)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<button className="flex flex-col items-center justify-center p-8 rounded-2xl border-2 border-dashed border-[var(--color-border-subtle)] text-[var(--color-text-tertiary)] hover:border-emerald-500 hover:text-[var(--color-primary)] transition-all">
|
<button className="flex flex-col items-center justify-center p-8 rounded-2xl border-2 border-dashed border-[var(--color-border-subtle)] text-[var(--color-text-tertiary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] transition-all bg-[var(--color-bg-primary)]/50">
|
||||||
<Plus size={24} className="mb-2" />
|
<Plus size={24} className="mb-2" />
|
||||||
<span className="text-xs font-medium">Transfer New Batch</span>
|
<span className="text-sm font-medium">Transfer New Batch</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'tasks' && (
|
{activeTab === 'tasks' && (
|
||||||
<Card className="bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] overflow-hidden">
|
<Card className="bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] overflow-hidden">
|
||||||
<div className="divide-y divide-slate-100 dark:divide-slate-800/50">
|
<div className="divide-y divide-[var(--color-border-subtle)]">
|
||||||
{[
|
{[
|
||||||
{ title: 'De-fan Fan Leaves', assignee: 'John D.', priority: 'HIGH', due: 'Today' },
|
{ title: 'De-fan Fan Leaves', assignee: 'John D.', priority: 'HIGH', due: 'Today' },
|
||||||
{ title: 'Irrigation Maintenance', assignee: 'Sarah K.', priority: 'MED', due: 'Tomorrow' },
|
{ title: 'Irrigation Maintenance', assignee: 'Sarah K.', priority: 'MED', due: 'Tomorrow' },
|
||||||
{ title: 'IPM Foliar Spray', assignee: 'John D.', priority: 'CRITICAL', due: '2h ago' },
|
{ title: 'IPM Foliar Spray', assignee: 'John D.', priority: 'CRITICAL', due: '2h ago' },
|
||||||
{ title: 'Verify Light Intensity', assignee: 'None', priority: 'LOW', due: 'Friday' },
|
{ title: 'Verify Light Intensity', assignee: 'None', priority: 'LOW', due: 'Friday' },
|
||||||
].map((task, i) => (
|
].map((task, i) => (
|
||||||
<div key={i} className="p-4 flex items-center justify-between hover:bg-slate-50 dark:hover:bg-slate-900/50 transition-colors cursor-pointer group">
|
<div key={i} className="p-4 flex items-center justify-between hover:bg-[var(--color-bg-tertiary)] transition-colors cursor-pointer group">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-2 h-2 rounded-full",
|
"w-2 h-2 rounded-full",
|
||||||
|
|
@ -217,8 +232,8 @@ export default function RoomDetailPage() {
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold text-[var(--color-text-primary)]">{task.title}</p>
|
<p className="text-sm font-bold text-[var(--color-text-primary)]">{task.title}</p>
|
||||||
<div className="flex items-center gap-3 mt-1 underline-offset-4 decoration-slate-300">
|
<div className="flex items-center gap-3 mt-1 underline-offset-4 decoration-slate-300">
|
||||||
<span className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest">Assigned: {task.assignee}</span>
|
<span className="text-xs font-medium text-[var(--color-text-tertiary)]">Assigned: {task.assignee}</span>
|
||||||
<span className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest">Due: {task.due}</span>
|
<span className="text-xs font-medium text-[var(--color-text-tertiary)]">Due: {task.due}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -236,21 +251,22 @@ export default function RoomDetailPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricCard({ icon: Icon, label, value, unit, color, data }: {
|
function MetricCard({ icon: Icon, label, value, unit, color, fill, data }: {
|
||||||
icon: any;
|
icon: any;
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
unit: string;
|
unit: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
fill: string; // added back to control sparkline background opacity
|
||||||
data: { value: number }[];
|
data: { value: number }[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-5 bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] relative group overflow-hidden">
|
<Card className="p-5 bg-[var(--color-bg-elevated)] border-[var(--color-border-subtle)] relative group overflow-hidden shadow-sm">
|
||||||
<div className="flex items-start justify-between relative z-10">
|
<div className="flex items-start justify-between relative z-10">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon size={14} className={color} />
|
<Icon size={14} className={color} />
|
||||||
<span className="text-[10px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.15em]">{label}</span>
|
<span className="text-xs font-bold text-[var(--color-text-tertiary)] tracking-wide">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-2xl font-bold text-[var(--color-text-primary)]">{value}</span>
|
<span className="text-2xl font-bold text-[var(--color-text-primary)]">{value}</span>
|
||||||
|
|
@ -258,11 +274,11 @@ function MetricCard({ icon: Icon, label, value, unit, color, data }: {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<SparklineLine data={data} color={color === 'text-[var(--color-error)]' ? '#f43f5e' : color === 'text-[var(--color-accent)]' ? '#3b82f6' : '#10b981'} />
|
<SparklineLine data={data} color={color.includes('error') ? '#f43f5e' : color.includes('accent') ? '#3b82f6' : '#10b981'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-slate-100 dark:bg-slate-800 overflow-hidden">
|
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-[var(--color-bg-tertiary)] overflow-hidden">
|
||||||
<div className={cn("h-full w-1/3 opacity-50", color.replace('text', 'bg'))} />
|
<div className={cn("h-full w-1/3 opacity-70", fill.replace('text-', 'bg-'))} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
@ -300,16 +316,16 @@ function TabButton({ active, icon: Icon, label, count, onClick }: { active: bool
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-1 py-4 text-xs font-bold uppercase tracking-[0.15em] transition-all relative",
|
"flex items-center gap-2 px-1 py-4 text-xs font-bold transition-all relative shrink-0",
|
||||||
active ? "text-[var(--color-primary)]" : "text-[var(--color-text-tertiary)] hover:text-slate-800 dark:hover:text-slate-200"
|
active ? "text-[var(--color-primary)]" : "text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={16} />
|
<Icon size={16} />
|
||||||
{label}
|
{label}
|
||||||
{count !== undefined && (
|
{count !== undefined && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"ml-1 px-1.5 py-0.5 rounded-full text-[9px]",
|
"ml-1 px-1.5 py-0.5 rounded-full text-[10px]",
|
||||||
active ? "bg-[var(--color-primary)] text-white" : "bg-slate-100 dark:bg-slate-800 text-[var(--color-text-tertiary)]"
|
active ? "bg-[var(--color-primary)] text-white" : "bg-[var(--color-bg-tertiary)] text-[var(--color-text-tertiary)]"
|
||||||
)}>
|
)}>
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue