Spaces:
Build error
Build error
| import { useState } from 'react'; | |
| import type { Task } from '../services/api'; | |
| import { PageParseAPI } from '../services/api'; | |
| import { | |
| Search, Calendar, AlertTriangle, CheckCircle, | |
| Circle, Trash2, Download, Check, X, Edit2, Copy, Database | |
| } from 'lucide-react'; | |
| interface TaskListProps { | |
| tasks: Task[]; | |
| onTasksChanged: () => void; | |
| variant?: 'full' | 'compact'; | |
| } | |
| export const TaskList = ({ tasks, onTasksChanged, variant = 'full' }: TaskListProps) => { | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [filterPriority, setFilterPriority] = useState('all'); | |
| const [filterStatus, setFilterStatus] = useState('all'); | |
| const [editingTaskId, setEditingTaskId] = useState<number | null>(null); | |
| const [copiedMD, setCopiedMD] = useState(false); | |
| const [copiedTaskId, setCopiedTaskId] = useState<number | null>(null); | |
| const [editName, setEditName] = useState(''); | |
| const [editDueDate, setEditDueDate] = useState(''); | |
| const [editPriority, setEditPriority] = useState<'high' | 'medium' | 'low'>('medium'); | |
| const [editCategory, setEditCategory] = useState(''); | |
| const filteredTasks = tasks.filter(t => { | |
| const q = searchQuery.toLowerCase(); | |
| const matchesSearch = t.task.toLowerCase().includes(q) || (t.category?.toLowerCase().includes(q) ?? false); | |
| const matchesPriority = filterPriority === 'all' || t.priority === filterPriority; | |
| const matchesStatus = filterStatus === 'all' || t.status === filterStatus; | |
| return matchesSearch && matchesPriority && matchesStatus; | |
| }); | |
| const handleToggleStatus = async (task: Task) => { | |
| const newStatus = task.status === 'todo' ? 'done' : 'todo'; | |
| try { await PageParseAPI.updateTask(task.id, { status: newStatus }); onTasksChanged(); } | |
| catch (err) { console.error(err); } | |
| }; | |
| const handleDelete = async (id: number) => { | |
| try { await PageParseAPI.deleteTask(id); onTasksChanged(); } | |
| catch (err) { console.error(err); } | |
| }; | |
| const startEditing = (task: Task) => { | |
| setEditingTaskId(task.id); | |
| setEditName(task.task); | |
| setEditDueDate(task.due_date || ''); | |
| setEditPriority(task.priority || 'medium'); | |
| setEditCategory(task.category || ''); | |
| }; | |
| const saveEditing = async (id: number) => { | |
| try { | |
| await PageParseAPI.updateTask(id, { task: editName, due_date: editDueDate || null, priority: editPriority, category: editCategory || null }); | |
| setEditingTaskId(null); | |
| onTasksChanged(); | |
| } catch (err) { console.error(err); } | |
| }; | |
| const handleCopyMD = async () => { | |
| const md = filteredTasks.map(t => `- [${t.status === 'done' ? 'x' : ' '}] ${t.task}${t.due_date ? ` (due: ${t.due_date})` : ''}`).join('\n'); | |
| try { await navigator.clipboard.writeText(md); setCopiedMD(true); setTimeout(() => setCopiedMD(false), 2000); } | |
| catch (err) { console.error(err); } | |
| }; | |
| const handleCopyTask = async (task: Task) => { | |
| try { await navigator.clipboard.writeText(task.task); setCopiedTaskId(task.id); setTimeout(() => setCopiedTaskId(null), 2000); } | |
| catch (err) { console.error(err); } | |
| }; | |
| const handleExportJSON = () => { | |
| const blob = new Blob([JSON.stringify(filteredTasks, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; a.download = `pageparse_${new Date().toISOString().split('T')[0]}.json`; | |
| a.click(); URL.revokeObjectURL(url); | |
| }; | |
| const handleExportCSV = () => { | |
| const headers = ['ID', 'Task', 'Due Date', 'Priority', 'Category', 'Status', 'Confidence']; | |
| const rows = filteredTasks.map(t => [t.id, `"${t.task.replace(/"/g, '""')}"`, t.due_date || '', t.priority || '', t.category || '', t.status || '', t.ocr_confidence.toFixed(2)]); | |
| const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n'); | |
| const blob = new Blob([csv], { type: 'text/csv' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; a.download = `pageparse_${new Date().toISOString().split('T')[0]}.csv`; | |
| a.click(); URL.revokeObjectURL(url); | |
| }; | |
| if (variant === 'compact') { | |
| return ( | |
| <div className="section"> | |
| <div className="section-header"> | |
| <h3><Database size={16} className="text-secondary" /> Recent Records ({filteredTasks.length})</h3> | |
| <div className="flex gap-2 items-center"> | |
| <button className="btn btn-sm btn-ghost" onClick={handleCopyMD} disabled={!filteredTasks.length}>{copiedMD ? <Check size={12} /> : <Copy size={12} />}</button> | |
| <button className="btn btn-sm btn-ghost" onClick={handleExportJSON} disabled={!filteredTasks.length}><Download size={12} /> JSON</button> | |
| <button className="btn btn-sm btn-ghost" onClick={handleExportCSV} disabled={!filteredTasks.length}><Download size={12} /> CSV</button> | |
| </div> | |
| </div> | |
| <div className="section-body"> | |
| <div className="flex gap-3 mb-3"> | |
| <div style={{ position: 'relative', flex: 1 }}> | |
| <Search size={14} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-tertiary)' }} /> | |
| <input className="input" style={{ paddingLeft: 32 }} placeholder="Search..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)} /> | |
| </div> | |
| <select value={filterPriority} onChange={e => setFilterPriority(e.target.value)} className="select"> | |
| <option value="all">All Priority</option> | |
| <option value="high">High</option> | |
| <option value="medium">Medium</option> | |
| <option value="low">Low</option> | |
| </select> | |
| <select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="select"> | |
| <option value="all">All Status</option> | |
| <option value="todo">To Do</option> | |
| <option value="done">Done</option> | |
| </select> | |
| </div> | |
| <div className="table-wrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th style={{ width: 32 }}></th> | |
| <th style={{ width: 80 }}>Type</th> | |
| <th>Task</th> | |
| <th style={{ width: 100 }}>Due</th> | |
| <th style={{ width: 80 }}>Priority</th> | |
| <th style={{ width: 80 }}>Confidence</th> | |
| <th style={{ width: 60 }}></th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {filteredTasks.slice(0, 8).map(task => ( | |
| <tr key={task.id} className={`${task.status === 'done' ? 'done' : ''} ${(task.ocr_confidence || 0) < 0.6 ? 'low-confidence' : ''}`}> | |
| <td> | |
| <button className="btn-icon" onClick={() => handleToggleStatus(task)}> | |
| {task.status === 'done' ? <CheckCircle size={16} style={{ color: 'var(--success)' }} /> : <Circle size={16} />} | |
| </button> | |
| </td> | |
| <td><span className="text-xs text-tertiary font-semibold" style={{ textTransform: 'uppercase' }}>{(task.type || 'task').replace('_', ' ')}</span></td> | |
| <td> | |
| <div className="flex gap-2 items-center"> | |
| <span className="task-text">{task.task}</span> | |
| {(task.ocr_confidence || 0) < 0.6 && <span className="badge badge-medium"><AlertTriangle size={10} /> Low Conf</span>} | |
| </div> | |
| </td> | |
| <td><span className="text-xs text-tertiary">{task.due_date || '—'}</span></td> | |
| <td>{task.priority && <span className={`badge badge-${task.priority}`}>{task.priority}</span>}</td> | |
| <td><span className="text-xs font-mono">{(task.ocr_confidence * 100).toFixed(0)}%</span></td> | |
| <td> | |
| <div className="flex gap-1"> | |
| <button className="btn-icon" onClick={() => handleCopyTask(task)}>{copiedTaskId === task.id ? <Check size={14} style={{ color: 'var(--success)' }} /> : <Copy size={14} />}</button> | |
| <button className="btn-icon" onClick={() => handleDelete(task.id)}><Trash2 size={14} style={{ color: 'var(--danger)' }} /></button> | |
| </div> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| {!filteredTasks.length && <div className="history-empty">No records found.</div>} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="section"> | |
| <div className="section-header"> | |
| <h3><Database size={16} className="text-secondary" /> All Records ({filteredTasks.length})</h3> | |
| <div className="flex gap-2 items-center"> | |
| <button className="btn btn-sm btn-ghost" onClick={handleCopyMD} disabled={!filteredTasks.length}>{copiedMD ? <Check size={12} /> : <Copy size={12} />} Copy MD</button> | |
| <button className="btn btn-sm btn-ghost" onClick={handleExportJSON} disabled={!filteredTasks.length}><Download size={12} /> JSON</button> | |
| <button className="btn btn-sm btn-ghost" onClick={handleExportCSV} disabled={!filteredTasks.length}><Download size={12} /> CSV</button> | |
| </div> | |
| </div> | |
| <div className="section-body"> | |
| <div className="flex gap-3 mb-3" style={{ flexWrap: 'wrap' }}> | |
| <div style={{ position: 'relative', flex: 1, minWidth: 200 }}> | |
| <Search size={14} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-tertiary)' }} /> | |
| <input className="input" style={{ paddingLeft: 32 }} placeholder="Search tasks or categories..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)} /> | |
| </div> | |
| <select value={filterPriority} onChange={e => setFilterPriority(e.target.value)} className="select"> | |
| <option value="all">All Priorities</option> | |
| <option value="high">High</option> | |
| <option value="medium">Medium</option> | |
| <option value="low">Low</option> | |
| </select> | |
| <select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="select"> | |
| <option value="all">All Statuses</option> | |
| <option value="todo">To Do</option> | |
| <option value="done">Completed</option> | |
| </select> | |
| </div> | |
| <div className="table-wrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th style={{ width: 32 }}></th> | |
| <th style={{ width: 80 }}>Type</th> | |
| <th>Description</th> | |
| <th style={{ width: 110 }}>Due Date</th> | |
| <th style={{ width: 80 }}>Priority</th> | |
| <th style={{ width: 100 }}>Category</th> | |
| <th style={{ width: 90 }}>Confidence</th> | |
| <th style={{ width: 100 }}>Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {filteredTasks.map(task => { | |
| const isEditing = editingTaskId === task.id; | |
| const isLowConf = (task.ocr_confidence || 0) < 0.6; | |
| return ( | |
| <tr key={task.id} className={`${task.status === 'done' ? 'done' : ''} ${isLowConf ? 'low-confidence' : ''}`}> | |
| <td> | |
| <button className="btn-icon" onClick={() => handleToggleStatus(task)}> | |
| {task.status === 'done' ? <CheckCircle size={16} style={{ color: 'var(--success)' }} /> : <Circle size={16} />} | |
| </button> | |
| </td> | |
| <td><span className="text-xs text-tertiary font-semibold" style={{ textTransform: 'uppercase' }}>{(task.type || 'task').replace('_', ' ')}</span></td> | |
| <td> | |
| {isEditing ? ( | |
| <input className="input" value={editName} onChange={e => setEditName(e.target.value)} style={{ padding: '4px 8px', fontSize: 13 }} /> | |
| ) : ( | |
| <div className="flex gap-2 items-center"> | |
| <span className="task-text">{task.task}</span> | |
| {isLowConf && <span className="badge badge-medium"><AlertTriangle size={10} /> Verify</span>} | |
| </div> | |
| )} | |
| </td> | |
| <td> | |
| {isEditing ? ( | |
| <input type="date" className="input" value={editDueDate} onChange={e => setEditDueDate(e.target.value)} style={{ padding: '4px 8px', fontSize: 13 }} /> | |
| ) : ( | |
| <span className="flex items-center gap-1 text-xs text-tertiary"><Calendar size={12} />{task.due_date || '—'}</span> | |
| )} | |
| </td> | |
| <td> | |
| {isEditing ? ( | |
| <select value={editPriority} onChange={e => setEditPriority(e.target.value as any)} className="select" style={{ padding: '4px 8px', fontSize: 13 }}> | |
| <option value="high">High</option> | |
| <option value="medium">Medium</option> | |
| <option value="low">Low</option> | |
| </select> | |
| ) : ( | |
| task.priority && <span className={`badge badge-${task.priority}`}>{task.priority}</span> | |
| )} | |
| </td> | |
| <td> | |
| {isEditing ? ( | |
| <input className="input" value={editCategory} onChange={e => setEditCategory(e.target.value)} placeholder="e.g. Work" style={{ padding: '4px 8px', fontSize: 13 }} /> | |
| ) : ( | |
| <span className="text-xs text-tertiary">{task.category || '—'}</span> | |
| )} | |
| </td> | |
| <td> | |
| <div className="flex items-center gap-1"> | |
| <span style={{ width: 6, height: 6, borderRadius: '50%', background: isLowConf ? 'var(--danger)' : (task.ocr_confidence || 0) < 0.85 ? 'var(--warning)' : 'var(--success)' }} /> | |
| <span className="text-xs font-mono">{(task.ocr_confidence * 100).toFixed(0)}%</span> | |
| </div> | |
| </td> | |
| <td> | |
| <div className="flex gap-1" style={{ justifyContent: 'flex-end' }}> | |
| {isEditing ? ( | |
| <> | |
| <button className="btn-icon" onClick={() => saveEditing(task.id)}><Check size={14} style={{ color: 'var(--success)' }} /></button> | |
| <button className="btn-icon" onClick={() => setEditingTaskId(null)}><X size={14} style={{ color: 'var(--danger)' }} /></button> | |
| </> | |
| ) : ( | |
| <> | |
| <button className="btn-icon" onClick={() => handleCopyTask(task)} title="Copy">{copiedTaskId === task.id ? <Check size={14} style={{ color: 'var(--success)' }} /> : <Copy size={14} />}</button> | |
| <button className="btn-icon" onClick={() => startEditing(task)} title="Edit"><Edit2 size={14} /></button> | |
| <button className="btn-icon" onClick={() => handleDelete(task.id)} title="Delete"><Trash2 size={14} style={{ color: 'var(--danger)' }} /></button> | |
| </> | |
| )} | |
| </div> | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| {!filteredTasks.length && <div className="history-empty">No records found matching the filters.</div>} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |