feat: AI chat now more accurate, has access to data from user, and can generate graphs, charts etc
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import type { ArtifactSpec } from '@/types/chat'
|
||||
import { ChatChart } from './ChatChart'
|
||||
import { ChatDataTable } from './ChatDataTable'
|
||||
import { ChatKpiCards } from './ChatKpiCards'
|
||||
import { ChatAgingBuckets } from './ChatAgingBuckets'
|
||||
|
||||
interface ArtifactRendererProps {
|
||||
artifact: ArtifactSpec
|
||||
}
|
||||
|
||||
export function ArtifactRenderer({ artifact }: ArtifactRendererProps) {
|
||||
switch (artifact.type) {
|
||||
case 'bar_chart':
|
||||
case 'line_chart':
|
||||
case 'pie_chart':
|
||||
case 'stacked_bar':
|
||||
return (
|
||||
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
|
||||
<ChatChart artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'table':
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<ChatDataTable artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'kpi_cards':
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<ChatKpiCards artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'aging_buckets':
|
||||
return (
|
||||
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
|
||||
<ChatAgingBuckets artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import type { AgingBucketsArtifact } from '@/types/chat'
|
||||
|
||||
const BUCKET_COLORS = [
|
||||
'bg-green-500',
|
||||
'bg-yellow-400',
|
||||
'bg-orange-400',
|
||||
'bg-red-400',
|
||||
'bg-red-600',
|
||||
]
|
||||
|
||||
interface ChatAgingBucketsProps {
|
||||
artifact: AgingBucketsArtifact
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE').format(Math.round(amount))
|
||||
}
|
||||
|
||||
export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
|
||||
const { title, buckets, total } = artifact
|
||||
|
||||
const maxAmount = Math.max(...buckets.map((b) => b.amount), 1)
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
<span className="text-sm font-bold tabular-nums">
|
||||
{formatAmount(total)} kr
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stacked bar */}
|
||||
{total > 0 && (
|
||||
<div className="flex h-6 rounded-md overflow-hidden mb-3">
|
||||
{buckets.map((bucket, i) => {
|
||||
const widthPercent = (bucket.amount / total) * 100
|
||||
if (widthPercent < 0.5) return null
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${BUCKET_COLORS[i % BUCKET_COLORS.length]} transition-all`}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
title={`${bucket.label}: ${formatAmount(bucket.amount)} kr`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-1.5">
|
||||
{buckets.map((bucket, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-sm ${BUCKET_COLORS[i % BUCKET_COLORS.length]}`}
|
||||
/>
|
||||
<span className="text-muted-foreground">{bucket.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{bucket.count} st
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatAmount(bucket.amount)} kr
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import type { ChartArtifact } from '@/types/chat'
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
'var(--color-chart-1, #3b82f6)',
|
||||
'var(--color-chart-2, #10b981)',
|
||||
'var(--color-chart-3, #f59e0b)',
|
||||
'var(--color-chart-4, #ef4444)',
|
||||
'var(--color-chart-5, #8b5cf6)',
|
||||
'var(--color-chart-6, #ec4899)',
|
||||
'var(--color-chart-7, #06b6d4)',
|
||||
'var(--color-chart-8, #84cc16)',
|
||||
]
|
||||
|
||||
function formatValue(value: number, unit?: string): string {
|
||||
const formatted = new Intl.NumberFormat('sv-SE').format(Math.round(value))
|
||||
return unit ? `${formatted} ${unit}` : formatted
|
||||
}
|
||||
|
||||
interface ChatChartProps {
|
||||
artifact: ChartArtifact
|
||||
}
|
||||
|
||||
export function ChatChart({ artifact }: ChatChartProps) {
|
||||
const { type, title, data, unit, subtitle } = artifact
|
||||
|
||||
const chartData = data.map((d, i) => ({
|
||||
...d,
|
||||
fill: d.color || DEFAULT_COLORS[i % DEFAULT_COLORS.length],
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{type === 'pie_chart' ? (
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius="80%"
|
||||
label={(props) => {
|
||||
const name = props.name ?? ''
|
||||
const percent = typeof props.percent === 'number' ? props.percent : 0
|
||||
return `${name} (${(percent * 100).toFixed(0)}%)`
|
||||
}}
|
||||
labelLine={false}
|
||||
>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => formatValue(Number(value ?? 0), unit)}
|
||||
/>
|
||||
</PieChart>
|
||||
) : type === 'line_chart' ? (
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => formatValue(v, unit)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => formatValue(Number(value ?? 0), unit)}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={DEFAULT_COLORS[0]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => formatValue(v, unit)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => formatValue(Number(value ?? 0), unit)}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import type { TableArtifact } from '@/types/chat'
|
||||
|
||||
interface ChatDataTableProps {
|
||||
artifact: TableArtifact
|
||||
}
|
||||
|
||||
function formatCell(value: string | number, align?: 'left' | 'right'): string {
|
||||
if (typeof value === 'number') {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: value % 1 !== 0 ? 2 : 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function ChatDataTable({ artifact }: ChatDataTableProps) {
|
||||
const { title, columns, rows, summary_row } = artifact
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h4 className="text-sm font-semibold mb-2">{title}</h4>
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-3 py-2 font-medium text-muted-foreground border-b border-border ${
|
||||
col.align === 'right' ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-border/50 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3 py-1.5 ${
|
||||
col.align === 'right' ? 'text-right tabular-nums' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
{formatCell(row[col.key], col.align)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{summary_row && (
|
||||
<tr className="bg-muted/30 font-semibold">
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3 py-2 border-t border-border ${
|
||||
col.align === 'right' ? 'text-right tabular-nums' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
{summary_row[col.key] !== undefined
|
||||
? formatCell(summary_row[col.key], col.align)
|
||||
: ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
import type { KpiCardsArtifact } from '@/types/chat'
|
||||
|
||||
interface ChatKpiCardsProps {
|
||||
artifact: KpiCardsArtifact
|
||||
}
|
||||
|
||||
export function ChatKpiCards({ artifact }: ChatKpiCardsProps) {
|
||||
const { title, cards } = artifact
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{title && <h4 className="text-sm font-semibold mb-2">{title}</h4>}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{cards.map((card, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-border bg-card p-3"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground mb-1">{card.label}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-lg font-bold tabular-nums">{card.value}</span>
|
||||
{card.trend && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${
|
||||
card.trend === 'up'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: card.trend === 'down'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{card.trend === 'up' && <TrendingUp className="h-3 w-3" />}
|
||||
{card.trend === 'down' && <TrendingDown className="h-3 w-3" />}
|
||||
{card.trend === 'flat' && <Minus className="h-3 w-3" />}
|
||||
{card.change}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user