- Step 10: Extended file upload for Excel/CSV/images/URLs (openpyxl, trafilatura) - Step 11: Content intelligence service with rule-based + LLM classification - Step 12: Slide mapping engine mapping content blocks to master deck layouts - Step 13: Chart data extractor, native PPTX chart service (bar/line/pie/gantt/waterfall), ChartDataEditor skeleton Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
281 lines
7.7 KiB
TypeScript
281 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
|
|
const CHART_TYPES = [
|
|
{ value: "bar", label: "Bar" },
|
|
{ value: "column", label: "Column" },
|
|
{ value: "line", label: "Line" },
|
|
{ value: "pie", label: "Pie" },
|
|
{ value: "doughnut", label: "Doughnut" },
|
|
{ value: "area", label: "Area" },
|
|
{ value: "scatter", label: "Scatter" },
|
|
{ value: "gantt", label: "Gantt" },
|
|
{ value: "waterfall", label: "Waterfall" },
|
|
];
|
|
|
|
export interface ChartSeries {
|
|
name: string;
|
|
values: number[];
|
|
}
|
|
|
|
export interface ChartDataPayload {
|
|
chart_type: string;
|
|
title: string;
|
|
categories: string[];
|
|
series: ChartSeries[];
|
|
unit?: string;
|
|
}
|
|
|
|
interface ChartDataEditorProps {
|
|
initialData?: ChartDataPayload;
|
|
onApply: (data: ChartDataPayload) => void;
|
|
onCancel?: () => void;
|
|
}
|
|
|
|
const DEFAULT_DATA: ChartDataPayload = {
|
|
chart_type: "column",
|
|
title: "Chart",
|
|
categories: ["Category 1", "Category 2", "Category 3"],
|
|
series: [{ name: "Series 1", values: [10, 20, 30] }],
|
|
};
|
|
|
|
export default function ChartDataEditor({
|
|
initialData,
|
|
onApply,
|
|
onCancel,
|
|
}: ChartDataEditorProps) {
|
|
const [data, setData] = useState<ChartDataPayload>(
|
|
initialData ?? DEFAULT_DATA
|
|
);
|
|
|
|
const updateCategory = useCallback(
|
|
(index: number, value: string) => {
|
|
setData((prev) => {
|
|
const cats = [...prev.categories];
|
|
cats[index] = value;
|
|
return { ...prev, categories: cats };
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
const updateSeriesName = useCallback(
|
|
(seriesIdx: number, name: string) => {
|
|
setData((prev) => {
|
|
const series = prev.series.map((s, i) =>
|
|
i === seriesIdx ? { ...s, name } : s
|
|
);
|
|
return { ...prev, series };
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
const updateCellValue = useCallback(
|
|
(seriesIdx: number, catIdx: number, value: string) => {
|
|
setData((prev) => {
|
|
const series = prev.series.map((s, i) => {
|
|
if (i !== seriesIdx) return s;
|
|
const values = [...s.values];
|
|
values[catIdx] = parseFloat(value) || 0;
|
|
return { ...s, values };
|
|
});
|
|
return { ...prev, series };
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
const addCategory = useCallback(() => {
|
|
setData((prev) => ({
|
|
...prev,
|
|
categories: [...prev.categories, `Category ${prev.categories.length + 1}`],
|
|
series: prev.series.map((s) => ({
|
|
...s,
|
|
values: [...s.values, 0],
|
|
})),
|
|
}));
|
|
}, []);
|
|
|
|
const removeCategory = useCallback(
|
|
(index: number) => {
|
|
setData((prev) => ({
|
|
...prev,
|
|
categories: prev.categories.filter((_, i) => i !== index),
|
|
series: prev.series.map((s) => ({
|
|
...s,
|
|
values: s.values.filter((_, i) => i !== index),
|
|
})),
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const addSeries = useCallback(() => {
|
|
setData((prev) => ({
|
|
...prev,
|
|
series: [
|
|
...prev.series,
|
|
{
|
|
name: `Series ${prev.series.length + 1}`,
|
|
values: new Array(prev.categories.length).fill(0),
|
|
},
|
|
],
|
|
}));
|
|
}, []);
|
|
|
|
const removeSeries = useCallback(
|
|
(index: number) => {
|
|
setData((prev) => ({
|
|
...prev,
|
|
series: prev.series.filter((_, i) => i !== index),
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4 p-4 border rounded-lg bg-background">
|
|
{/* Header controls */}
|
|
<div className="flex items-center gap-3">
|
|
<Input
|
|
value={data.title}
|
|
onChange={(e) => setData((prev) => ({ ...prev, title: e.target.value }))}
|
|
placeholder="Chart title"
|
|
className="max-w-[240px]"
|
|
/>
|
|
<Select
|
|
value={data.chart_type}
|
|
onValueChange={(val) => setData((prev) => ({ ...prev, chart_type: val }))}
|
|
>
|
|
<SelectTrigger className="w-[140px]">
|
|
<SelectValue placeholder="Chart type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CHART_TYPES.map((ct) => (
|
|
<SelectItem key={ct.value} value={ct.value}>
|
|
{ct.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
value={data.unit ?? ""}
|
|
onChange={(e) =>
|
|
setData((prev) => ({
|
|
...prev,
|
|
unit: e.target.value || undefined,
|
|
}))
|
|
}
|
|
placeholder="Unit (e.g. %, $)"
|
|
className="max-w-[100px]"
|
|
/>
|
|
</div>
|
|
|
|
{/* Spreadsheet grid */}
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[140px]">Category</TableHead>
|
|
{data.series.map((s, si) => (
|
|
<TableHead key={si} className="min-w-[120px]">
|
|
<div className="flex items-center gap-1">
|
|
<Input
|
|
value={s.name}
|
|
onChange={(e) => updateSeriesName(si, e.target.value)}
|
|
className="h-7 text-xs"
|
|
/>
|
|
{data.series.length > 1 && (
|
|
<button
|
|
onClick={() => removeSeries(si)}
|
|
className="text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</TableHead>
|
|
))}
|
|
<TableHead className="w-[40px]" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{data.categories.map((cat, ci) => (
|
|
<TableRow key={ci}>
|
|
<TableCell>
|
|
<Input
|
|
value={cat}
|
|
onChange={(e) => updateCategory(ci, e.target.value)}
|
|
className="h-7 text-xs"
|
|
/>
|
|
</TableCell>
|
|
{data.series.map((s, si) => (
|
|
<TableCell key={si}>
|
|
<Input
|
|
type="number"
|
|
value={s.values[ci] ?? 0}
|
|
onChange={(e) => updateCellValue(si, ci, e.target.value)}
|
|
className="h-7 text-xs"
|
|
/>
|
|
</TableCell>
|
|
))}
|
|
<TableCell>
|
|
{data.categories.length > 1 && (
|
|
<button
|
|
onClick={() => removeCategory(ci)}
|
|
className="text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={addCategory}>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
Row
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={addSeries}>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
Series
|
|
</Button>
|
|
<div className="ml-auto flex gap-2">
|
|
{onCancel && (
|
|
<Button variant="ghost" size="sm" onClick={onCancel}>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
<Button size="sm" onClick={() => onApply(data)}>
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|