ppt-tool/frontend/app/(presentation-generator)/components/ChartDataEditor.tsx
Vadym Samoilenko a2bd4cfefa Phase 3: Content Pipeline — file parsing, content intelligence, slide mapping, native charts
- 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>
2026-02-26 15:54:04 +00:00

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>
);
}