import { useState, useMemo, useEffect, useRef } from 'react' import ReactDOM from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { tripsApi } from '../../api/client' import apiClient from '../../api/client' import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { formatDate as fmtDate } from '../../utils/formatters' import { CheckSquare, Square, Plus, ChevronRight, Flag, X, Check, Calendar, User, FolderPlus, AlertCircle, ListChecks, Inbox, CheckCheck, Trash2, } from 'lucide-react' import type { TodoItem } from '../../types' const KAT_COLORS = [ '#3b82f6', '#a855f7', '#ec4899', '#22c55e', '#f97316', '#06b6d4', '#ef4444', '#eab308', '#8b5cf6', '#14b8a6', ] const PRIO_CONFIG: Record = { 1: { label: 'P1', color: '#ef4444' }, 2: { label: 'P2', color: '#f59e0b' }, 3: { label: 'P3', color: '#3b82f6' }, } function katColor(kat: string, allCategories: string[]) { const idx = allCategories.indexOf(kat) if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length] let h = 0 for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0 return KAT_COLORS[Math.abs(h) % KAT_COLORS.length] } type FilterType = 'all' | 'my' | 'overdue' | 'done' | string interface Member { id: number; username: string; avatar: string | null } export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) { const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore() const canEdit = useCanDo('packing_edit') const toast = useToast() const { t, locale } = useTranslation() const formatDate = (d: string) => fmtDate(d, locale) || d const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768) useEffect(() => { const mq = window.matchMedia('(max-width: 767px)') const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches) mq.addEventListener('change', handler) return () => mq.removeEventListener('change', handler) }, []) const [filter, setFilter] = useState('all') const [selectedId, setSelectedId] = useState(null) const [isAddingNew, setIsAddingNew] = useState(false) const lastHandledAddSignal = useRef(addItemSignal) useEffect(() => { if (addItemSignal !== lastHandledAddSignal.current && addItemSignal > 0) { setSelectedId(null) setIsAddingNew(true) } lastHandledAddSignal.current = addItemSignal }, [addItemSignal]) const [sortByPrio, setSortByPrio] = useState(false) const [addingCategory, setAddingCategory] = useState(false) const [newCategoryName, setNewCategoryName] = useState('') const [members, setMembers] = useState([]) const [currentUserId, setCurrentUserId] = useState(null) useEffect(() => { apiClient.get(`/trips/${tripId}/members`).then(r => { const owner = r.data?.owner const mems = r.data?.members || [] const all = owner ? [owner, ...mems] : mems setMembers(all) setCurrentUserId(r.data?.current_user_id || null) }).catch(() => {}) }, [tripId]) const categories = useMemo(() => { const cats = new Set() items.forEach(i => { if (i.category) cats.add(i.category) }) return Array.from(cats).sort() }, [items]) const today = new Date().toISOString().split('T')[0] const filtered = useMemo(() => { let result: TodoItem[] if (filter === 'all') result = items.filter(i => !i.checked) else if (filter === 'done') result = items.filter(i => !!i.checked) else if (filter === 'my') result = items.filter(i => !i.checked && i.assigned_user_id === currentUserId) else if (filter === 'overdue') result = items.filter(i => !i.checked && i.due_date && i.due_date < today) else result = items.filter(i => i.category === filter) if (sortByPrio) result = [...result].sort((a, b) => { const ap = a.priority || 99 const bp = b.priority || 99 return ap - bp }) return result }, [items, filter, currentUserId, today, sortByPrio]) const selectedItem = items.find(i => i.id === selectedId) || null const totalCount = items.length const doneCount = items.filter(i => !!i.checked).length const overdueCount = items.filter(i => !i.checked && i.due_date && i.due_date < today).length const myCount = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId).length : 0 const addCategory = () => { const name = newCategoryName.trim() if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return } addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any) .then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) }) .catch(err => toast.error(err instanceof Error ? err.message : t('common.error'))) } // Get category count (non-done items) const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length // Sidebar filter item const SidebarItem = ({ id, icon: Icon, label, count, color }: { id: string; icon: any; label: string; count: number; color?: string }) => ( ) // Filter title const filterTitle = (() => { if (filter === 'all') return t('todo.filter.all') if (filter === 'done') return t('todo.filter.done') if (filter === 'my') return t('todo.filter.my') if (filter === 'overdue') return t('todo.filter.overdue') return filter })() return (
{/* ── Left Sidebar ── */}
{/* Progress Card */} {!isMobile &&
{totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0}%
0 ? `${Math.round((doneCount / totalCount) * 100)}%` : '0%', background: '#22c55e', borderRadius: 2, transition: 'width 0.3s' }} />
{doneCount} / {totalCount} {t('todo.completed')}
} {/* Smart filters */} {!isMobile &&
{t('todo.sidebar.tasks')}
} !i.checked).length} /> {/* Sort by */} {!isMobile &&
{t('todo.sidebar.sortBy')}
} {/* Categories */} {!isMobile &&
{t('todo.sidebar.categories')}
} {isMobile &&
} {categories.map(cat => ( ))} {canEdit && ( addingCategory && !isMobile ? (
setNewCategoryName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCategoryName('') } }} placeholder={t('todo.newCategory')} style={{ flex: 1, fontSize: 12, padding: '4px 6px', border: '1px solid var(--border-primary)', borderRadius: 5, background: 'var(--bg-hover)', color: 'var(--text-primary)', fontFamily: 'inherit', minWidth: 0 }} />
) : ( ) )}
{/* ── Middle: Task List ── */}
{/* Header */}

{filterTitle}

{filtered.length}
{/* Task list */}
{filtered.length === 0 ? null : ( filtered.map(item => { const done = !!item.checked const assignedUser = members.find(m => m.id === item.assigned_user_id) const isOverdue = item.due_date && !done && item.due_date < today const isSelected = selectedId === item.id const catColor = item.category ? katColor(item.category, categories) : null return (
{ setSelectedId(isSelected ? null : item.id); setIsAddingNew(false) }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 20px', borderBottom: '1px solid var(--border-faint)', cursor: 'pointer', background: isSelected ? 'var(--bg-hover)' : 'transparent', transition: 'background 0.1s', }} onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.02)' }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}> {/* Checkbox */} {/* Content */}
{item.name}
{/* Description preview */} {item.description && (
{item.description}
)} {/* Inline badges */} {(item.priority || item.due_date || catColor || assignedUser) && (
{item.priority > 0 && PRIO_CONFIG[item.priority] && ( {PRIO_CONFIG[item.priority].label} )} {item.due_date && ( {formatDate(item.due_date)} )} {catColor && ( {item.category} )} {assignedUser && ( {assignedUser.avatar ? ( ) : ( {assignedUser.username.charAt(0).toUpperCase()} )} {assignedUser.username} )}
)}
{/* Chevron */}
) }) )}
{/* ── Right: Detail Pane ── */} {selectedItem && !isAddingNew && !isMobile && ( setSelectedId(null)} /> )} {selectedItem && !isAddingNew && isMobile && (
{ if (e.target === e.currentTarget) setSelectedId(null) }} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> setSelectedId(null)} />
)} {isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
{ if (e.target === e.currentTarget) setIsAddingNew(false) }} className="modal-backdrop" style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}>
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px' } } }}> { setIsAddingNew(false); setSelectedId(id) }} onClose={() => setIsAddingNew(false)} />
, document.body )} {isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal(
{ if (e.target === e.currentTarget) setIsAddingNew(false) }} className="modal-backdrop" style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
{ if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}> { setIsAddingNew(false); setSelectedId(id) }} onClose={() => setIsAddingNew(false)} />
, document.body )}
) } // ── Detail Pane (right side) ────────────────────────────────────────────── function DetailPane({ item, tripId, categories, members, onClose }: { item: TodoItem; tripId: number; categories: string[]; members: Member[]; onClose: () => void; }) { const { updateTodoItem, deleteTodoItem } = useTripStore() const canEdit = useCanDo('packing_edit') const toast = useToast() const { t } = useTranslation() const [name, setName] = useState(item.name) const [desc, setDesc] = useState(item.description || '') const [dueDate, setDueDate] = useState(item.due_date || '') const [category, setCategory] = useState(item.category || '') const [assignedUserId, setAssignedUserId] = useState(item.assigned_user_id) const [priority, setPriority] = useState(item.priority || 0) const [saving, setSaving] = useState(false) // Sync when selected item changes useEffect(() => { setName(item.name) setDesc(item.description || '') setDueDate(item.due_date || '') setCategory(item.category || '') setAssignedUserId(item.assigned_user_id) setPriority(item.priority || 0) }, [item.id, item.name, item.description, item.due_date, item.category, item.assigned_user_id, item.priority]) const hasChanges = name !== item.name || desc !== (item.description || '') || dueDate !== (item.due_date || '') || category !== (item.category || '') || assignedUserId !== item.assigned_user_id || priority !== (item.priority || 0) const save = async () => { if (!name.trim() || !hasChanges) return setSaving(true) try { await updateTodoItem(tripId, item.id, { name: name.trim(), description: desc || null, due_date: dueDate || null, category: category || null, assigned_user_id: assignedUserId, priority, } as any) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) } setSaving(false) } const handleDelete = async () => { try { await deleteTodoItem(tripId, item.id) onClose() } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) } } const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' } const inputStyle: React.CSSProperties = { width: '100%', fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', } return (
{/* Header */}
{t('todo.detail.title')}
{/* Form */}
{/* Name */}
setName(e.target.value)} disabled={!canEdit} style={{ ...inputStyle, fontSize: 15, fontWeight: 600, border: 'none', padding: '4px 0', background: 'transparent' }} placeholder={t('todo.namePlaceholder')} />
{/* Description */}