cohorta/src/components/focus-group-session/NotesPanel.tsx
Vadym Samoilenko 0f7b8a5a9e fix(ui): fix crashes, dark theme tokens, Invalid Date bug
- FocusGroupSession: add missing DiscussionGuideViewer import (was crashing)
- PersonaProfile: wrap TabsTrigger in TabsList (RovingFocusGroup crash)
- Dashboard: fix Invalid Date bug — tx.ts vs tx.created_at field mismatch
- ParticipantPanel, UserCard, SyntheticUsers: replace hardcoded light-mode
  colors (bg-blue-50, text-slate-*, bg-white) with dark theme tokens
- AutonomousDashboard, NotesPanel, QuickNoteModal: same dark theme sweep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:24:28 +01:00

281 lines
No EOL
9.2 KiB
TypeScript
Executable file

import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Download, StickyNote, Trash2, Quote } from 'lucide-react';
import { toast } from 'sonner';
import { focusGroupsApi } from '@/lib/api';
import { Note } from './types';
interface NotesPanelProps {
focusGroupId: string;
focusGroupName?: string;
onNoteClick?: (associatedMessageId: string) => void;
}
const NotesPanel = ({
focusGroupId,
focusGroupName = 'Focus Group',
onNoteClick
}: NotesPanelProps) => {
const [notes, setNotes] = useState<Note[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isDeleting, setIsDeleting] = useState<string | null>(null);
// Fetch notes when component mounts or focusGroupId changes
useEffect(() => {
fetchNotes();
}, [focusGroupId]);
const fetchNotes = async () => {
try {
setIsLoading(true);
const response = await focusGroupsApi.getNotes(focusGroupId);
if (response.data && Array.isArray(response.data)) {
const formattedNotes = response.data.map((note: any) => ({
...note,
timestamp: new Date(note.timestamp),
createdAt: new Date(note.createdAt)
}));
setNotes(sortNotesByCreatedAt(formattedNotes));
}
} catch (error) {
console.error('Error fetching notes:', error);
toast.error('Failed to load notes', {
description: 'Please refresh the page to try again.'
});
} finally {
setIsLoading(false);
}
};
const handleDeleteNote = async (noteId: string) => {
setIsDeleting(noteId);
try {
await focusGroupsApi.deleteNote(focusGroupId, noteId);
setNotes(notes.filter(note => note.id !== noteId));
toast.success('Note deleted successfully');
} catch (error) {
console.error('Error deleting note:', error);
toast.error('Failed to delete note', {
description: 'Please try again.'
});
} finally {
setIsDeleting(null);
}
};
const handleNoteClick = (note: Note) => {
if (note.associatedMessageId && onNoteClick) {
onNoteClick(note.associatedMessageId);
} else {
toast.info('No associated message', {
description: 'This note is not linked to a specific discussion point.'
});
}
};
const exportNotes = () => {
if (notes.length === 0) {
toast.warning('No notes to export', {
description: 'Create some notes first before exporting.'
});
return;
}
// Create markdown content
const markdownContent = generateMarkdownExport();
// Create and download file
const element = document.createElement('a');
const file = new Blob([markdownContent], { type: 'text/markdown' });
element.href = URL.createObjectURL(file);
element.download = `${focusGroupName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_notes.md`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
toast.success('Notes exported successfully', {
description: `Downloaded ${notes.length} notes as Markdown file.`
});
};
const generateMarkdownExport = (): string => {
const lines = [
`# Notes: ${focusGroupName}`,
'',
`Exported on: ${new Date().toLocaleString()}`,
`Total notes: ${notes.length}`,
'',
'---',
''
];
notes.forEach((note, index) => {
lines.push(`## Note ${index + 1}`);
lines.push('');
lines.push(`**Created:** ${note.createdAt.toLocaleString()}`);
if (note.sectionInfo?.sectionTitle) {
lines.push(`**Section:** ${note.sectionInfo.sectionTitle}`);
}
lines.push(`**Elapsed Time:** ${formatElapsedTime(note.elapsedTime)}`);
lines.push('');
lines.push('**Content:**');
lines.push(note.content);
lines.push('');
lines.push('---');
lines.push('');
});
return lines.join('\n');
};
const formatElapsedTime = (milliseconds: number): string => {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const formatTimestamp = (date: Date): string => {
return date.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// Helper function to sort notes by creation time (newest first)
const sortNotesByCreatedAt = (notesToSort: Note[]): Note[] => {
return [...notesToSort].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
};
// Public method to add a new note (called from parent)
const addNote = (note: Note) => {
setNotes(prevNotes => sortNotesByCreatedAt([...prevNotes, note]));
};
// Expose addNote method to parent via ref or callback
useEffect(() => {
// Store reference in a way parent can access
(window as any).notesPanelAddNote = addNote;
return () => {
delete (window as any).notesPanelAddNote;
};
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header with export button */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<StickyNote className="h-5 w-5 text-primary mr-2" />
<h2 className="font-sf text-xl font-semibold">Notes</h2>
{notes.length > 0 && (
<span className="ml-2 text-sm text-muted-foreground">({notes.length})</span>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={exportNotes}
disabled={notes.length === 0}
>
<Download className="mr-2 h-4 w-4" />
Export Notes
</Button>
</div>
{/* Notes list */}
<ScrollArea className="flex-1">
{notes.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 text-center bg-secondary/30 rounded-lg">
<StickyNote className="h-8 w-8 text-muted-foreground/60 mb-3" />
<p className="text-muted-foreground">No notes yet.</p>
<p className="text-sm text-muted-foreground mt-2">
Click the "Note" button during the session to add contextual notes.
</p>
</div>
) : (
<div className="space-y-4">
{notes.map((note) => (
<Card
key={note.id}
className="hover:shadow-md transition-shadow cursor-pointer group"
onClick={() => handleNoteClick(note)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-sm font-medium text-muted-foreground">
{formatTimestamp(note.createdAt)}
</CardTitle>
{note.sectionInfo?.sectionTitle && (
<div className="text-xs text-muted-foreground mt-1">
<span>{note.sectionInfo.sectionTitle}</span>
</div>
)}
</div>
<div className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
{note.associatedMessageId && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
handleNoteClick(note);
}}
title="Go to discussion point"
>
<Quote className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
handleDeleteNote(note.id);
}}
disabled={isDeleting === note.id}
title="Delete note"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-sm text-foreground/80 whitespace-pre-wrap">
{note.content}
</p>
</CardContent>
</Card>
))}
</div>
)}
</ScrollArea>
</div>
);
};
export default NotesPanel;