mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 12:52:09 +01:00
Compare commits
2 Commits
5042a6c016
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| a002d5ef76 | |||
| c4cfe2f389 |
@ -91,16 +91,44 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const metrics = sessionMetrics(chatSessions, companyConfigForMetrics);
|
||||
|
||||
// Calculate date range from sessions
|
||||
// Calculate date range from the FILTERED sessions to match what's actually displayed
|
||||
let dateRange: { minDate: string; maxDate: string } | null = null;
|
||||
if (prismaSessions.length > 0) {
|
||||
const dates = prismaSessions.map(s => new Date(s.startTime)).sort((a, b) => a.getTime() - b.getTime());
|
||||
dateRange = {
|
||||
minDate: dates[0].toISOString().split('T')[0], // First session date
|
||||
maxDate: dates[dates.length - 1].toISOString().split('T')[0] // Last session date
|
||||
let availableDataRange: { minDate: string; maxDate: string } | null = null;
|
||||
|
||||
// Get the full available range for reference
|
||||
const allSessions = await prisma.session.findMany({
|
||||
where: {
|
||||
companyId: user.companyId,
|
||||
},
|
||||
select: {
|
||||
startTime: true,
|
||||
},
|
||||
orderBy: {
|
||||
startTime: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
if (allSessions.length > 0) {
|
||||
availableDataRange = {
|
||||
minDate: allSessions[0].startTime.toISOString().split('T')[0], // First session date
|
||||
maxDate: allSessions[allSessions.length - 1].startTime.toISOString().split('T')[0] // Last session date
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate date range from the filtered sessions (what's actually being displayed)
|
||||
if (prismaSessions.length > 0) {
|
||||
const sortedFilteredSessions = prismaSessions.sort((a, b) =>
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
|
||||
);
|
||||
dateRange = {
|
||||
minDate: sortedFilteredSessions[0].startTime.toISOString().split('T')[0],
|
||||
maxDate: sortedFilteredSessions[sortedFilteredSessions.length - 1].startTime.toISOString().split('T')[0]
|
||||
};
|
||||
} else if (availableDataRange) {
|
||||
// If no filtered sessions but we have available data, use the available range
|
||||
dateRange = availableDataRange;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
metrics,
|
||||
csvUrl: user.company.csvUrl,
|
||||
|
||||
@ -84,12 +84,15 @@ function DashboardContent() {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle date range changes
|
||||
// Handle date range changes with proper memoization
|
||||
const handleDateRangeChange = useCallback((startDate: string, endDate: string) => {
|
||||
// Only update if dates actually changed to prevent unnecessary API calls
|
||||
if (startDate !== selectedStartDate || endDate !== selectedEndDate) {
|
||||
setSelectedStartDate(startDate);
|
||||
setSelectedEndDate(endDate);
|
||||
fetchMetrics(startDate, endDate);
|
||||
}, []);
|
||||
}
|
||||
}, [selectedStartDate, selectedEndDate]);
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect if not authenticated
|
||||
@ -216,9 +219,9 @@ function DashboardContent() {
|
||||
};
|
||||
|
||||
return [
|
||||
{ name: "Positive", value: sentimentData.positive, color: "hsl(var(--chart-1))" },
|
||||
{ name: "Neutral", value: sentimentData.neutral, color: "hsl(var(--chart-2))" },
|
||||
{ name: "Negative", value: sentimentData.negative, color: "hsl(var(--chart-3))" },
|
||||
{ name: "Positive", value: sentimentData.positive, color: "rgb(34, 197, 94)" },
|
||||
{ name: "Neutral", value: sentimentData.neutral, color: "rgb(168, 162, 158)" },
|
||||
{ name: "Negative", value: sentimentData.negative, color: "rgb(239, 68, 68)" },
|
||||
];
|
||||
};
|
||||
|
||||
@ -281,31 +284,33 @@ function DashboardContent() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Modern Header */}
|
||||
<Card className="border-0 bg-linear-to-r from-primary/5 via-primary/10 to-primary/5">
|
||||
<CardHeader>
|
||||
{/* Apple-Style Unified Header */}
|
||||
<Card className="border-0 bg-white shadow-sm">
|
||||
<CardHeader className="pb-6">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{/* Top row: Company info and actions */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{company.name}</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 tracking-tight">{company.name}</h1>
|
||||
<Badge variant="secondary" className="text-xs font-medium bg-gray-100 text-gray-700 border-0">
|
||||
Analytics Dashboard
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-sm text-gray-500">
|
||||
Last updated{" "}
|
||||
<span className="font-medium">
|
||||
<span className="font-medium text-gray-700">
|
||||
{new Date(metrics.lastUpdated || Date.now()).toLocaleString()}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing || isAuditor}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
className="gap-2 bg-blue-600 hover:bg-blue-700 border-0 shadow-sm"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
{refreshing ? "Refreshing..." : "Refresh"}
|
||||
@ -313,11 +318,11 @@ function DashboardContent() {
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline" size="sm" className="border-gray-200 hover:bg-gray-50">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end" className="border-gray-200 shadow-lg">
|
||||
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign out
|
||||
@ -326,20 +331,83 @@ function DashboardContent() {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range Controls */}
|
||||
{dateRange && (
|
||||
<div className="border-t border-gray-100 pt-6">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">Date Range:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">From:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedStartDate}
|
||||
min={dateRange.minDate}
|
||||
max={dateRange.maxDate}
|
||||
onChange={(e) => handleDateRangeChange(e.target.value, selectedEndDate)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">To:</label>
|
||||
<input
|
||||
type="date"
|
||||
value={selectedEndDate}
|
||||
min={dateRange.minDate}
|
||||
max={dateRange.maxDate}
|
||||
onChange={(e) => handleDateRangeChange(selectedStartDate, e.target.value)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const endDate = new Date().toISOString().split('T')[0];
|
||||
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
handleDateRangeChange(startDate, endDate);
|
||||
}}
|
||||
className="text-xs border-gray-200 hover:bg-gray-50"
|
||||
>
|
||||
Last 7 days
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const endDate = new Date().toISOString().split('T')[0];
|
||||
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
handleDateRangeChange(startDate, endDate);
|
||||
}}
|
||||
className="text-xs border-gray-200 hover:bg-gray-50"
|
||||
>
|
||||
Last 30 days
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeChange(dateRange.minDate, dateRange.maxDate)}
|
||||
className="text-xs border-gray-200 hover:bg-gray-50"
|
||||
>
|
||||
All time
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Available data: {new Date(dateRange.minDate).toLocaleDateString()} - {new Date(dateRange.maxDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Date Range Picker - Temporarily disabled to debug infinite loop */}
|
||||
{/* {dateRange && (
|
||||
<DateRangePicker
|
||||
minDate={dateRange.minDate}
|
||||
maxDate={dateRange.maxDate}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
initialStartDate={selectedStartDate}
|
||||
initialEndDate={selectedEndDate}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
{/* Modern Metrics Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<MetricCard
|
||||
@ -372,6 +440,7 @@ function DashboardContent() {
|
||||
value: metrics.avgSessionTimeTrend ?? 0,
|
||||
isPositive: (metrics.avgSessionTimeTrend ?? 0) >= 0,
|
||||
}}
|
||||
variant="primary"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
@ -390,6 +459,7 @@ function DashboardContent() {
|
||||
value={`€${metrics.avgDailyCosts?.toFixed(4) || '0.0000'}`}
|
||||
icon={<Euro className="h-5 w-5" />}
|
||||
description="Average per day"
|
||||
variant="warning"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
@ -397,6 +467,7 @@ function DashboardContent() {
|
||||
value={metrics.peakUsageTime || 'N/A'}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
description="Busiest hour"
|
||||
variant="primary"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
@ -415,6 +486,7 @@ function DashboardContent() {
|
||||
value={Object.keys(metrics.languages || {}).length}
|
||||
icon={<Globe className="h-5 w-5" />}
|
||||
description="Languages detected"
|
||||
variant="success"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
145
app/globals.css
145
app/globals.css
@ -43,71 +43,71 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: 255 255 255;
|
||||
--foreground: 15 23 42;
|
||||
--card: 255 255 255;
|
||||
--card-foreground: 15 23 42;
|
||||
--popover: 255 255 255;
|
||||
--popover-foreground: 15 23 42;
|
||||
--primary: 0 123 255;
|
||||
--primary-foreground: 255 255 255;
|
||||
--secondary: 245 245 245;
|
||||
--secondary-foreground: 51 51 51;
|
||||
--muted: 248 250 252;
|
||||
--muted-foreground: 100 116 139;
|
||||
--accent: 245 245 245;
|
||||
--accent-foreground: 51 51 51;
|
||||
--destructive: 239 68 68;
|
||||
--border: 229 231 235;
|
||||
--input: 229 231 235;
|
||||
--ring: 0 123 255;
|
||||
--chart-1: 0 123 255;
|
||||
--chart-2: 255 20 147;
|
||||
--chart-3: 50 205 50;
|
||||
--chart-4: 138 43 226;
|
||||
--chart-5: 255 215 0;
|
||||
--sidebar: 248 250 252;
|
||||
--sidebar-foreground: 15 23 42;
|
||||
--sidebar-primary: 0 123 255;
|
||||
--sidebar-primary-foreground: 255 255 255;
|
||||
--sidebar-accent: 245 245 245;
|
||||
--sidebar-accent-foreground: 51 51 51;
|
||||
--sidebar-border: 229 231 235;
|
||||
--sidebar-ring: 0 123 255;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--background: 15 23 42;
|
||||
--foreground: 248 250 252;
|
||||
--card: 30 41 59;
|
||||
--card-foreground: 248 250 252;
|
||||
--popover: 30 41 59;
|
||||
--popover-foreground: 248 250 252;
|
||||
--primary: 59 130 246;
|
||||
--primary-foreground: 15 23 42;
|
||||
--secondary: 51 65 85;
|
||||
--secondary-foreground: 248 250 252;
|
||||
--muted: 51 65 85;
|
||||
--muted-foreground: 148 163 184;
|
||||
--accent: 51 65 85;
|
||||
--accent-foreground: 248 250 252;
|
||||
--destructive: 248 113 113;
|
||||
--border: 51 65 85;
|
||||
--input: 51 65 85;
|
||||
--ring: 59 130 246;
|
||||
--chart-1: 59 130 246;
|
||||
--chart-2: 236 72 153;
|
||||
--chart-3: 34 197 94;
|
||||
--chart-4: 147 51 234;
|
||||
--chart-5: 251 191 36;
|
||||
--sidebar: 30 41 59;
|
||||
--sidebar-foreground: 248 250 252;
|
||||
--sidebar-primary: 59 130 246;
|
||||
--sidebar-primary-foreground: 248 250 252;
|
||||
--sidebar-accent: 51 65 85;
|
||||
--sidebar-accent-foreground: 248 250 252;
|
||||
--sidebar-border: 51 65 85;
|
||||
--sidebar-ring: 59 130 246;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@ -115,6 +115,25 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
|
||||
/* Apple-style scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@ -128,9 +128,9 @@ export function SentimentChart({ sentimentData }: SentimentChartProps) {
|
||||
sentimentData.negative,
|
||||
],
|
||||
backgroundColor: [
|
||||
"rgba(34, 197, 94, 0.8)", // green
|
||||
"rgba(249, 115, 22, 0.8)", // orange
|
||||
"rgba(239, 68, 68, 0.8)", // red
|
||||
"rgba(37, 99, 235, 0.8)", // blue (primary)
|
||||
"rgba(107, 114, 128, 0.8)", // gray
|
||||
"rgba(236, 72, 153, 0.8)", // pink
|
||||
],
|
||||
borderWidth: 1,
|
||||
},
|
||||
@ -196,12 +196,12 @@ export function LanguagePieChart({ languages }: LanguagePieChartProps) {
|
||||
{
|
||||
data,
|
||||
backgroundColor: [
|
||||
"rgba(59, 130, 246, 0.8)",
|
||||
"rgba(16, 185, 129, 0.8)",
|
||||
"rgba(249, 115, 22, 0.8)",
|
||||
"rgba(236, 72, 153, 0.8)",
|
||||
"rgba(139, 92, 246, 0.8)",
|
||||
"rgba(107, 114, 128, 0.8)",
|
||||
"rgba(37, 99, 235, 0.8)", // blue (primary)
|
||||
"rgba(107, 114, 128, 0.8)", // gray
|
||||
"rgba(236, 72, 153, 0.8)", // pink
|
||||
"rgba(34, 197, 94, 0.8)", // lime green
|
||||
"rgba(168, 85, 247, 0.8)", // purple
|
||||
"rgba(251, 191, 36, 0.8)", // yellow
|
||||
],
|
||||
borderWidth: 1,
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, memo } from "react";
|
||||
|
||||
interface DateRangePickerProps {
|
||||
minDate: string;
|
||||
@ -10,7 +10,7 @@ interface DateRangePickerProps {
|
||||
initialEndDate?: string;
|
||||
}
|
||||
|
||||
export default function DateRangePicker({
|
||||
function DateRangePicker({
|
||||
minDate,
|
||||
maxDate,
|
||||
onDateRangeChange,
|
||||
@ -19,10 +19,26 @@ export default function DateRangePicker({
|
||||
}: DateRangePickerProps) {
|
||||
const [startDate, setStartDate] = useState(initialStartDate || minDate);
|
||||
const [endDate, setEndDate] = useState(initialEndDate || maxDate);
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only notify parent component when dates change, not when the callback changes
|
||||
// Update local state when props change (e.g., when date range is loaded from API)
|
||||
if (initialStartDate && initialStartDate !== startDate) {
|
||||
setStartDate(initialStartDate);
|
||||
}
|
||||
if (initialEndDate && initialEndDate !== endDate) {
|
||||
setEndDate(initialEndDate);
|
||||
}
|
||||
}, [initialStartDate, initialEndDate]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only notify parent component after initial render and when dates actually change
|
||||
// This prevents the infinite loop by not including onDateRangeChange in dependencies
|
||||
if (isInitializedRef.current) {
|
||||
onDateRangeChange(startDate, endDate);
|
||||
} else {
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
const handleStartDateChange = (newStartDate: string) => {
|
||||
@ -151,3 +167,6 @@ export default function DateRangePicker({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export memoized component as default
|
||||
export default memo(DateRangePicker);
|
||||
|
||||
@ -20,10 +20,10 @@ interface ResponseTimeDistributionProps {
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background p-3 shadow-md">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3 shadow-md">
|
||||
<p className="text-sm font-medium text-gray-900">{label}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium text-gray-900">
|
||||
{payload[0].value}
|
||||
</span>{" "}
|
||||
responses
|
||||
@ -66,11 +66,11 @@ export default function ResponseTimeDistribution({
|
||||
label = `${i}-${i + 1} sec`;
|
||||
}
|
||||
|
||||
// Determine color based on response time
|
||||
// Determine color based on response time using cohesive palette
|
||||
let color;
|
||||
if (i <= 2) color = "hsl(var(--chart-1))"; // Green for fast
|
||||
else if (i <= 5) color = "hsl(var(--chart-4))"; // Yellow for medium
|
||||
else color = "hsl(var(--chart-3))"; // Red for slow
|
||||
if (i <= 2) color = "rgb(37, 99, 235)"; // Blue for fast (primary color)
|
||||
else if (i <= 5) color = "rgb(107, 114, 128)"; // Gray for medium
|
||||
else color = "rgb(236, 72, 153)"; // Pink for slow
|
||||
|
||||
return {
|
||||
name: label,
|
||||
@ -85,18 +85,18 @@ export default function ResponseTimeDistribution({
|
||||
<BarChart data={chartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeOpacity={0.3}
|
||||
stroke="rgb(229, 231, 235)"
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
stroke="rgb(100, 116, 139)"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
stroke="rgb(100, 116, 139)"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
@ -104,7 +104,7 @@ export default function ResponseTimeDistribution({
|
||||
value: 'Number of Responses',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { textAnchor: 'middle' }
|
||||
style: { textAnchor: 'middle', fill: 'rgb(100, 116, 139)' }
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
@ -122,14 +122,14 @@ export default function ResponseTimeDistribution({
|
||||
{/* Average line */}
|
||||
<ReferenceLine
|
||||
x={Math.floor(average)}
|
||||
stroke="hsl(var(--primary))"
|
||||
stroke="rgb(0, 123, 255)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
label={{
|
||||
value: `Avg: ${average.toFixed(1)}s`,
|
||||
position: "top" as const,
|
||||
style: {
|
||||
fill: "hsl(var(--primary))",
|
||||
fill: "rgb(0, 123, 255)",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500"
|
||||
}
|
||||
@ -140,14 +140,14 @@ export default function ResponseTimeDistribution({
|
||||
{targetResponseTime && (
|
||||
<ReferenceLine
|
||||
x={Math.floor(targetResponseTime)}
|
||||
stroke="hsl(var(--chart-2))"
|
||||
stroke="rgb(255, 20, 147)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: `Target: ${targetResponseTime}s`,
|
||||
position: "top" as const,
|
||||
style: {
|
||||
fill: "hsl(var(--chart-2))",
|
||||
fill: "rgb(255, 20, 147)",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500"
|
||||
}
|
||||
|
||||
@ -11,9 +11,9 @@ interface TopQuestionsChartProps {
|
||||
export default function TopQuestionsChart({ data, title = "Top 5 Asked Questions" }: TopQuestionsChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">{title}</h3>
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No questions data available
|
||||
</div>
|
||||
</div>
|
||||
@ -24,36 +24,38 @@ export default function TopQuestionsChart({ data, title = "Top 5 Asked Questions
|
||||
const maxCount = Math.max(...data.map(q => q.count));
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">{title}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{data.map((question, index) => {
|
||||
const percentage = maxCount > 0 ? (question.count / maxCount) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={index} className="relative">
|
||||
{/* Question text */}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<p className="text-sm text-gray-700 font-medium leading-tight pr-4 flex-1">
|
||||
<div key={index} className="group">
|
||||
{/* Rank and Question */}
|
||||
<div className="flex items-start gap-4 mb-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-gray-100 text-gray-900 text-sm font-semibold rounded-full flex items-center justify-center">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 leading-relaxed mb-2">
|
||||
{question.question}
|
||||
</p>
|
||||
<span className="text-sm font-semibold text-gray-900 bg-gray-100 px-2 py-1 rounded-md whitespace-nowrap">
|
||||
{question.count}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 mr-4">
|
||||
<div className="w-full bg-gray-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-in-out"
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rank indicator */}
|
||||
<div className="absolute -left-2 top-0 w-6 h-6 bg-blue-600 text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900 min-w-0">
|
||||
{question.count} times
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -61,10 +63,10 @@ export default function TopQuestionsChart({ data, title = "Top 5 Asked Questions
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>Total questions analyzed</span>
|
||||
<span className="font-medium">
|
||||
<div className="mt-8 pt-6 border-t border-gray-100">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">Total questions analyzed</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{data.reduce((sum, q) => sum + q.count, 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -43,11 +43,11 @@ export default function ModernBarChart({
|
||||
title,
|
||||
dataKey = "value",
|
||||
colors = [
|
||||
"hsl(var(--chart-1))",
|
||||
"hsl(var(--chart-2))",
|
||||
"hsl(var(--chart-3))",
|
||||
"hsl(var(--chart-4))",
|
||||
"hsl(var(--chart-5))",
|
||||
"rgb(37, 99, 235)", // Blue (primary)
|
||||
"rgb(107, 114, 128)", // Gray
|
||||
"rgb(236, 72, 153)", // Pink
|
||||
"rgb(34, 197, 94)", // Lime green
|
||||
"rgb(168, 85, 247)", // Purple
|
||||
],
|
||||
height = 300,
|
||||
className,
|
||||
@ -64,12 +64,12 @@ export default function ModernBarChart({
|
||||
<BarChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeOpacity={0.3}
|
||||
stroke="rgb(229, 231, 235)"
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
stroke="rgb(100, 116, 139)"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
@ -78,7 +78,7 @@ export default function ModernBarChart({
|
||||
height={80}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
stroke="rgb(100, 116, 139)"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
|
||||
@ -67,11 +67,11 @@ export default function ModernDonutChart({
|
||||
title,
|
||||
centerText,
|
||||
colors = [
|
||||
"hsl(var(--chart-1))",
|
||||
"hsl(var(--chart-2))",
|
||||
"hsl(var(--chart-3))",
|
||||
"hsl(var(--chart-4))",
|
||||
"hsl(var(--chart-5))",
|
||||
"rgb(37, 99, 235)", // Blue (primary)
|
||||
"rgb(107, 114, 128)", // Gray
|
||||
"rgb(236, 72, 153)", // Pink
|
||||
"rgb(34, 197, 94)", // Lime green
|
||||
"rgb(168, 85, 247)", // Purple
|
||||
],
|
||||
height = 300,
|
||||
className,
|
||||
@ -105,7 +105,7 @@ export default function ModernDonutChart({
|
||||
key={`cell-${index}`}
|
||||
fill={entry.color || colors[index % colors.length]}
|
||||
className="hover:opacity-80 cursor-pointer"
|
||||
stroke="hsl(var(--background))"
|
||||
stroke="white"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -44,7 +44,7 @@ export default function ModernLineChart({
|
||||
data,
|
||||
title,
|
||||
dataKey = "value",
|
||||
color = "hsl(var(--primary))",
|
||||
color = "rgb(37, 99, 235)",
|
||||
gradient = true,
|
||||
height = 300,
|
||||
className,
|
||||
@ -71,18 +71,18 @@ export default function ModernLineChart({
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeOpacity={0.3}
|
||||
stroke="rgb(229, 231, 235)"
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
stroke="rgb(100, 116, 139)"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
stroke="rgb(100, 116, 139)"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
|
||||
@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"bg-white text-gray-900 flex flex-col gap-6 rounded-2xl border border-gray-100 py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -51,31 +51,20 @@ export default function MetricCard({
|
||||
const getVariantClasses = () => {
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return "border-primary/20 bg-linear-to-br from-primary/5 to-primary/10";
|
||||
return "border border-blue-100 bg-white shadow-sm hover:shadow-md";
|
||||
case "success":
|
||||
return "border-green-200 bg-linear-to-br from-green-50 to-green-100 dark:border-green-800 dark:from-green-950 dark:to-green-900";
|
||||
return "border border-green-100 bg-white shadow-sm hover:shadow-md";
|
||||
case "warning":
|
||||
return "border-amber-200 bg-linear-to-br from-amber-50 to-amber-100 dark:border-amber-800 dark:from-amber-950 dark:to-amber-900";
|
||||
return "border border-pink-100 bg-white shadow-sm hover:shadow-md";
|
||||
case "danger":
|
||||
return "border-red-200 bg-linear-to-br from-red-50 to-red-100 dark:border-red-800 dark:from-red-950 dark:to-red-900";
|
||||
return "border border-red-100 bg-white shadow-sm hover:shadow-md";
|
||||
default:
|
||||
return "border-border bg-linear-to-br from-card to-muted/20";
|
||||
return "border border-gray-100 bg-white shadow-sm hover:shadow-md";
|
||||
}
|
||||
};
|
||||
|
||||
const getIconClasses = () => {
|
||||
switch (variant) {
|
||||
case "primary":
|
||||
return "bg-primary/10 text-primary border-primary/20";
|
||||
case "success":
|
||||
return "bg-green-100 text-green-600 border-green-200 dark:bg-green-900 dark:text-green-400 dark:border-green-800";
|
||||
case "warning":
|
||||
return "bg-amber-100 text-amber-600 border-amber-200 dark:bg-amber-900 dark:text-amber-400 dark:border-amber-800";
|
||||
case "danger":
|
||||
return "bg-red-100 text-red-600 border-red-200 dark:bg-red-900 dark:text-red-400 dark:border-red-800";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
return "bg-gray-50 text-gray-900 border-gray-100";
|
||||
};
|
||||
|
||||
const getTrendIcon = () => {
|
||||
@ -105,13 +94,11 @@ export default function MetricCard({
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Subtle gradient overlay */}
|
||||
<div className="absolute inset-0 bg-linear-to-br from-white/50 to-transparent dark:from-white/5 pointer-events-none" />
|
||||
|
||||
<CardHeader className="pb-3 relative">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground leading-none">
|
||||
<p className="text-sm font-medium text-gray-900 leading-none">
|
||||
{title}
|
||||
</p>
|
||||
{description && (
|
||||
@ -137,7 +124,7 @@ export default function MetricCard({
|
||||
<CardContent className="relative">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-bold tracking-tight">
|
||||
<p className="text-2xl font-bold tracking-tight text-gray-900">
|
||||
{value ?? "—"}
|
||||
</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user