feat: implement sections 6-7 - span selection, preview, and label assignment popover
This commit is contained in:
parent
c9d2cbfc4b
commit
586f02ed69
11 changed files with 647 additions and 22483 deletions
|
|
@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 're
|
|||
import { createChart, IChartApi, ISeriesApi, CandlestickData, Time } from 'lightweight-charts';
|
||||
import { useTheme } from 'next-themes';
|
||||
import SvgOverlay from './SvgOverlay';
|
||||
import SpanAnnotationManager from './SpanAnnotationManager';
|
||||
|
||||
interface Candle {
|
||||
time: number;
|
||||
|
|
@ -36,6 +37,31 @@ type AnnotationType = {
|
|||
is_active: number;
|
||||
};
|
||||
|
||||
interface SpanAnnotation {
|
||||
id: number;
|
||||
chart_id: number;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
label: string;
|
||||
confidence: number | null;
|
||||
outcome: string | null;
|
||||
notes: string | null;
|
||||
sub_spans: any;
|
||||
color: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface SpanLabelType {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
color: string;
|
||||
hotkey: string | null;
|
||||
is_active: number;
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface CandleChartProps {
|
||||
activeTool: string | null;
|
||||
onAnnotationChange?: () => void;
|
||||
|
|
@ -43,6 +69,11 @@ interface CandleChartProps {
|
|||
selectedLabelId?: number | null;
|
||||
onLabelSelect?: (id: number) => void;
|
||||
activeChartId?: number | null;
|
||||
spanAnnotations?: SpanAnnotation[];
|
||||
spanLabelTypes?: SpanLabelType[];
|
||||
selectedSpanId?: number | null;
|
||||
onSpanAnnotationsChange?: () => void;
|
||||
onSelectedSpanChange?: (spanId: number | null) => void;
|
||||
}
|
||||
|
||||
export interface CandleChartHandle {
|
||||
|
|
@ -50,7 +81,19 @@ export interface CandleChartHandle {
|
|||
}
|
||||
|
||||
const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||
({ activeTool, onAnnotationChange, selectedColor, selectedLabelId, onLabelSelect, activeChartId }, ref) => {
|
||||
({
|
||||
activeTool,
|
||||
onAnnotationChange,
|
||||
selectedColor,
|
||||
selectedLabelId,
|
||||
onLabelSelect,
|
||||
activeChartId,
|
||||
spanAnnotations = [],
|
||||
spanLabelTypes = [],
|
||||
selectedSpanId = null,
|
||||
onSpanAnnotationsChange,
|
||||
onSelectedSpanChange,
|
||||
}, ref) => {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
||||
|
|
@ -416,6 +459,18 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
selectedColor={selectedColor}
|
||||
activeChartId={activeChartId}
|
||||
/>
|
||||
<SpanAnnotationManager
|
||||
chart={chartRef.current}
|
||||
series={seriesRef.current}
|
||||
activeTool={activeTool}
|
||||
candles={candles}
|
||||
spanAnnotations={spanAnnotations}
|
||||
spanLabelTypes={spanLabelTypes}
|
||||
selectedSpanId={selectedSpanId}
|
||||
onSpanAnnotationsChange={onSpanAnnotationsChange || (() => {})}
|
||||
onSelectedSpanChange={onSelectedSpanChange || (() => {})}
|
||||
activeChartId={activeChartId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
347
src/components/SpanAnnotationManager.tsx
Normal file
347
src/components/SpanAnnotationManager.tsx
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { IChartApi, ISeriesApi, Time } from 'lightweight-charts';
|
||||
import { SpanRectanglePrimitive, SpanData } from './SpanRectanglePrimitive';
|
||||
import SpanPopover from './SpanPopover';
|
||||
|
||||
interface Candle {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
}
|
||||
|
||||
interface SpanAnnotation {
|
||||
id: number;
|
||||
chart_id: number;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
label: string;
|
||||
confidence: number | null;
|
||||
outcome: string | null;
|
||||
notes: string | null;
|
||||
sub_spans: any;
|
||||
color: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface SpanLabelType {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
color: string;
|
||||
hotkey: string | null;
|
||||
is_active: number;
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface SpanAnnotationManagerProps {
|
||||
chart: IChartApi | null;
|
||||
series: ISeriesApi<'Candlestick'> | null;
|
||||
activeTool: string | null;
|
||||
candles: Candle[];
|
||||
spanAnnotations: SpanAnnotation[];
|
||||
spanLabelTypes: SpanLabelType[];
|
||||
selectedSpanId: number | null;
|
||||
onSpanAnnotationsChange: () => void;
|
||||
onSelectedSpanChange: (spanId: number | null) => void;
|
||||
activeChartId: number | null;
|
||||
}
|
||||
|
||||
type InteractionState = 'idle' | 'first-click-done' | 'popover-open';
|
||||
|
||||
export default function SpanAnnotationManager({
|
||||
chart,
|
||||
series,
|
||||
activeTool,
|
||||
candles,
|
||||
spanAnnotations,
|
||||
spanLabelTypes,
|
||||
selectedSpanId,
|
||||
onSpanAnnotationsChange,
|
||||
onSelectedSpanChange,
|
||||
activeChartId,
|
||||
}: SpanAnnotationManagerProps) {
|
||||
const [interactionState, setInteractionState] = useState<InteractionState>('idle');
|
||||
const [startCandle, setStartCandle] = useState<Candle | null>(null);
|
||||
const [endCandle, setEndCandle] = useState<Candle | null>(null);
|
||||
const [previewPrimitive, setPreviewPrimitive] = useState<SpanRectanglePrimitive | null>(null);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const primitivesRef = useRef<Map<number, SpanRectanglePrimitive>>(new Map());
|
||||
|
||||
// Find nearest candle to a timestamp
|
||||
const findNearestCandle = (timestamp: number): Candle | null => {
|
||||
if (candles.length === 0) return null;
|
||||
return candles.reduce((prev, curr) => {
|
||||
return Math.abs(curr.time - timestamp) < Math.abs(prev.time - timestamp) ? curr : prev;
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate price range for candles in a span
|
||||
const calculatePriceRange = (start: Candle, end: Candle): { max_high: number; min_low: number } => {
|
||||
const startIdx = candles.findIndex((c) => c.time === start.time);
|
||||
const endIdx = candles.findIndex((c) => c.time === end.time);
|
||||
|
||||
if (startIdx === -1 || endIdx === -1) {
|
||||
return { max_high: Math.max(start.high, end.high), min_low: Math.min(start.low, end.low) };
|
||||
}
|
||||
|
||||
const [minIdx, maxIdx] = [Math.min(startIdx, endIdx), Math.max(startIdx, endIdx)];
|
||||
const spanCandles = candles.slice(minIdx, maxIdx + 1);
|
||||
|
||||
const max_high = Math.max(...spanCandles.map((c) => c.high));
|
||||
const min_low = Math.min(...spanCandles.map((c) => c.low));
|
||||
|
||||
return { max_high, min_low };
|
||||
};
|
||||
|
||||
// Render span annotations as primitives
|
||||
useEffect(() => {
|
||||
if (!series || !chart) return;
|
||||
|
||||
// Clear existing primitives
|
||||
primitivesRef.current.forEach((primitive) => {
|
||||
series.detachPrimitive(primitive);
|
||||
});
|
||||
primitivesRef.current.clear();
|
||||
|
||||
// Create primitives for each span annotation
|
||||
spanAnnotations.forEach((span) => {
|
||||
const { max_high, min_low } = calculatePriceRange(
|
||||
{ time: span.start_time, open: 0, high: 0, low: 0, close: 0 },
|
||||
{ time: span.end_time, open: 0, high: 0, low: 0, close: 0 }
|
||||
);
|
||||
|
||||
// If we can't calculate price range from candles, use sensible defaults
|
||||
const spanData: SpanData = {
|
||||
id: span.id,
|
||||
start_time: span.start_time,
|
||||
end_time: span.end_time,
|
||||
label: span.label,
|
||||
color: span.color,
|
||||
max_high: max_high,
|
||||
min_low: min_low,
|
||||
};
|
||||
|
||||
const primitive = new SpanRectanglePrimitive({
|
||||
data: spanData,
|
||||
isSelected: span.id === selectedSpanId,
|
||||
});
|
||||
|
||||
series.attachPrimitive(primitive);
|
||||
primitivesRef.current.set(span.id, primitive);
|
||||
});
|
||||
|
||||
// Request chart update
|
||||
chart.timeScale().fitContent();
|
||||
}, [spanAnnotations, selectedSpanId, series, chart, candles]);
|
||||
|
||||
// Handle clicks on chart for span tool
|
||||
useEffect(() => {
|
||||
if (!chart || !series || activeTool !== 'span') {
|
||||
// Clean up preview if tool changes
|
||||
if (previewPrimitive && series) {
|
||||
series.detachPrimitive(previewPrimitive);
|
||||
setPreviewPrimitive(null);
|
||||
}
|
||||
setInteractionState('idle');
|
||||
setStartCandle(null);
|
||||
setEndCandle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleClick = (param: any) => {
|
||||
if (!param.point) return;
|
||||
|
||||
const time = chart.timeScale().coordinateToTime(param.point.x);
|
||||
if (!time) return;
|
||||
|
||||
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
||||
const nearestCandle = findNearestCandle(timestamp);
|
||||
|
||||
if (!nearestCandle) return;
|
||||
|
||||
if (interactionState === 'idle') {
|
||||
// First click: set start candle
|
||||
setStartCandle(nearestCandle);
|
||||
setInteractionState('first-click-done');
|
||||
} else if (interactionState === 'first-click-done') {
|
||||
// Second click: set end candle and open popover
|
||||
setEndCandle(nearestCandle);
|
||||
setInteractionState('popover-open');
|
||||
setPopoverOpen(true);
|
||||
|
||||
// Clean up preview primitive
|
||||
if (previewPrimitive) {
|
||||
series.detachPrimitive(previewPrimitive);
|
||||
setPreviewPrimitive(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chart.subscribeClick(handleClick);
|
||||
|
||||
return () => {
|
||||
chart.unsubscribeClick(handleClick);
|
||||
};
|
||||
}, [chart, series, activeTool, interactionState, candles, previewPrimitive]);
|
||||
|
||||
// Handle mouse move for preview
|
||||
useEffect(() => {
|
||||
if (!chart || !series || activeTool !== 'span' || interactionState !== 'first-click-done' || !startCandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (param: any) => {
|
||||
if (!param.point) return;
|
||||
|
||||
const time = chart.timeScale().coordinateToTime(param.point.x);
|
||||
if (!time) return;
|
||||
|
||||
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
||||
const cursorCandle = findNearestCandle(timestamp);
|
||||
|
||||
if (!cursorCandle) return;
|
||||
|
||||
// Calculate price range for preview
|
||||
const { max_high, min_low } = calculatePriceRange(startCandle, cursorCandle);
|
||||
|
||||
// Swap if end < start
|
||||
const [start_time, end_time] =
|
||||
startCandle.time <= cursorCandle.time
|
||||
? [startCandle.time, cursorCandle.time]
|
||||
: [cursorCandle.time, startCandle.time];
|
||||
|
||||
const previewData: SpanData = {
|
||||
id: -1, // Preview ID
|
||||
start_time,
|
||||
end_time,
|
||||
label: 'PREVIEW',
|
||||
color: '#888888',
|
||||
max_high,
|
||||
min_low,
|
||||
};
|
||||
|
||||
// Remove old preview primitive
|
||||
if (previewPrimitive) {
|
||||
series.detachPrimitive(previewPrimitive);
|
||||
}
|
||||
|
||||
// Create new preview primitive
|
||||
const newPreview = new SpanRectanglePrimitive({
|
||||
data: previewData,
|
||||
isSelected: false,
|
||||
});
|
||||
|
||||
series.attachPrimitive(newPreview);
|
||||
setPreviewPrimitive(newPreview);
|
||||
};
|
||||
|
||||
chart.subscribeCrosshairMove(handleMouseMove);
|
||||
|
||||
return () => {
|
||||
chart.unsubscribeCrosshairMove(handleMouseMove);
|
||||
};
|
||||
}, [chart, series, activeTool, interactionState, startCandle, candles, previewPrimitive]);
|
||||
|
||||
// Handle Escape key to cancel span selection
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && activeTool === 'span') {
|
||||
if (interactionState === 'first-click-done') {
|
||||
// Cancel span selection
|
||||
setInteractionState('idle');
|
||||
setStartCandle(null);
|
||||
setEndCandle(null);
|
||||
|
||||
// Clean up preview
|
||||
if (previewPrimitive && series) {
|
||||
series.detachPrimitive(previewPrimitive);
|
||||
setPreviewPrimitive(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [activeTool, interactionState, previewPrimitive, series]);
|
||||
|
||||
// Handle popover save
|
||||
const handlePopoverSave = async (data: {
|
||||
label: string;
|
||||
confidence: number | null;
|
||||
outcome: string | null;
|
||||
notes: string | null;
|
||||
}) => {
|
||||
if (!startCandle || !endCandle || !activeChartId) return;
|
||||
|
||||
// Swap if end < start
|
||||
const [start_time, end_time] =
|
||||
startCandle.time <= endCandle.time
|
||||
? [startCandle.time, endCandle.time]
|
||||
: [endCandle.time, startCandle.time];
|
||||
|
||||
// Find label type color
|
||||
const labelType = spanLabelTypes.find((t) => t.name === data.label);
|
||||
const color = labelType?.color || '#2196F3';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/span-annotations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chart_id: activeChartId,
|
||||
start_time,
|
||||
end_time,
|
||||
label: data.label,
|
||||
confidence: data.confidence,
|
||||
outcome: data.outcome,
|
||||
notes: data.notes,
|
||||
color,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onSpanAnnotationsChange();
|
||||
setPopoverOpen(false);
|
||||
setInteractionState('idle');
|
||||
setStartCandle(null);
|
||||
setEndCandle(null);
|
||||
} else {
|
||||
console.error('Failed to create span annotation');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating span annotation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle popover cancel
|
||||
const handlePopoverCancel = () => {
|
||||
setPopoverOpen(false);
|
||||
setInteractionState('idle');
|
||||
setStartCandle(null);
|
||||
setEndCandle(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<SpanPopover
|
||||
open={popoverOpen}
|
||||
onOpenChange={setPopoverOpen}
|
||||
spanLabelTypes={spanLabelTypes}
|
||||
initialData={
|
||||
startCandle && endCandle
|
||||
? {
|
||||
start_time: Math.min(startCandle.time, endCandle.time),
|
||||
end_time: Math.max(startCandle.time, endCandle.time),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSave={handlePopoverSave}
|
||||
onCancel={handlePopoverCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
221
src/components/SpanPopover.tsx
Normal file
221
src/components/SpanPopover.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
|
||||
interface SpanLabelType {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
color: string;
|
||||
hotkey: string | null;
|
||||
is_active: number;
|
||||
sort_order: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface SpanData {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
label?: string;
|
||||
confidence?: number | null;
|
||||
outcome?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
interface SpanPopoverProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
spanLabelTypes: SpanLabelType[];
|
||||
initialData?: SpanData;
|
||||
onSave: (data: {
|
||||
label: string;
|
||||
confidence: number | null;
|
||||
outcome: string | null;
|
||||
notes: string | null;
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function SpanPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
spanLabelTypes,
|
||||
initialData,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: SpanPopoverProps) {
|
||||
const [label, setLabel] = useState<string>(initialData?.label || '');
|
||||
const [confidence, setConfidence] = useState<number>(initialData?.confidence || 3);
|
||||
const [outcome, setOutcome] = useState<string>(initialData?.outcome || 'none');
|
||||
const [notes, setNotes] = useState<string>(initialData?.notes || '');
|
||||
|
||||
// Reset form when dialog opens with new initial data
|
||||
useEffect(() => {
|
||||
if (open && initialData) {
|
||||
setLabel(initialData.label || '');
|
||||
setConfidence(initialData.confidence || 3);
|
||||
setOutcome(initialData.outcome || 'none');
|
||||
setNotes(initialData.notes || '');
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!label) return; // Validation: label is required
|
||||
|
||||
onSave({
|
||||
label,
|
||||
confidence: confidence,
|
||||
outcome: outcome === 'none' ? null : outcome,
|
||||
notes: notes.trim() || null,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setLabel('');
|
||||
setConfidence(3);
|
||||
setOutcome('none');
|
||||
setNotes('');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form
|
||||
setLabel('');
|
||||
setConfidence(3);
|
||||
setOutcome('none');
|
||||
setNotes('');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Span Annotation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign a pattern label and optional metadata to this span.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Label Selection */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="label">Pattern Label *</Label>
|
||||
<Select value={label} onValueChange={setLabel}>
|
||||
<SelectTrigger id="label">
|
||||
<SelectValue placeholder="Select a pattern label" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{spanLabelTypes
|
||||
.filter((type) => type.is_active === 1)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((type) => (
|
||||
<SelectItem key={type.id} value={type.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: type.color }}
|
||||
/>
|
||||
{type.display_name}
|
||||
{type.hotkey && (
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
({type.hotkey})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Confidence Slider */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="confidence">
|
||||
Confidence: {confidence}
|
||||
</Label>
|
||||
<Slider
|
||||
id="confidence"
|
||||
min={1}
|
||||
max={5}
|
||||
step={1}
|
||||
value={[confidence]}
|
||||
onValueChange={(values) => setConfidence(values[0])}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>1 (Low)</span>
|
||||
<span>5 (High)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outcome Selection */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="outcome">Outcome</Label>
|
||||
<Select value={outcome} onValueChange={setOutcome}>
|
||||
<SelectTrigger id="outcome">
|
||||
<SelectValue placeholder="Select outcome" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="win">Win</SelectItem>
|
||||
<SelectItem value="loss">Loss</SelectItem>
|
||||
<SelectItem value="breakeven">Break Even</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Notes Textarea */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Add any notes about this pattern..."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!label}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue