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'
import { KAT_COLORS, PRIO_CONFIG, katColor, type FilterType, type Member } from './todoListModel'
import { useTodoList } from './useTodoList'
import TodoRow from './TodoRow'
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) {
// Layout component: state/effects/derived/handlers live in useTodoList.
const {
canEdit, t, formatDate, toggleTodoItem,
isMobile, filter, setFilter, selectedId, setSelectedId,
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
members, categories, today, filtered, selectedItem,
totalCount, doneCount, overdueCount, myCount,
addCategory, catCount,
} = useTodoList(tripId, items, addItemSignal)
// 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 => (
{ setSelectedId(id); setIsAddingNew(false) }}
onToggle={(id, checked) => toggleTodoItem(tripId, id, checked)}
/>
))
)}
{/* ── 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="trek-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="trek-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 trip = useTripStore((s) => s.trip)
const can = useCanDo()
const canEdit = can('packing_edit', trip)
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 [addingCategory, setAddingCategoryInline] = useState(false)
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 labelClass = 'block text-xs font-medium text-content-secondary mb-1'
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 */}
{/* Priority */}
{[0, 1, 2, 3].map(p => {
const cfg = PRIO_CONFIG[p]
const isActive = priority === p
return (
)
})}
{/* Category */}
{addingCategory ? (
setCategory(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
placeholder={t('todo.newCategory')}
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
/>
) : (
setCategory(String(v))}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c, label: c,
icon: ,
})),
...(category && !categories.includes(category) ? [{
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
icon: ,
}] : []),
]}
placeholder={t('todo.noCategory')}
size="sm"
disabled={!canEdit}
/>
{canEdit && (
)}
)}
{/* Due date */}
setDueDate(v)}
/>
{/* Assigned to */}
setAssignedUserId(v ? Number(v) : null)}
options={[
{ value: '', label: t('todo.unassigned'), icon: },
...members.map(m => ({
value: String(m.id),
label: m.username,
icon: m.avatar ? (
) : (
{m.username.charAt(0).toUpperCase()}
),
})),
]}
placeholder={t('todo.unassigned')}
size="sm"
disabled={!canEdit}
/>
{/* Footer actions */}
{canEdit && (
)}
)
}
// ── New Task Pane (right side, for creating) ──────────────────────────────
function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated, onClose }: {
tripId: number; categories: string[]; members: Member[]; defaultCategory: string | null;
onCreated: (id: number) => void; onClose: () => void;
}) {
const { addTodoItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const [dueDate, setDueDate] = useState('')
const [category, setCategory] = useState(defaultCategory || '')
const [addingCategory, setAddingCategoryInline] = useState(false)
const [assignedUserId, setAssignedUserId] = useState(null)
const [priority, setPriority] = useState(0)
const [saving, setSaving] = useState(false)
const labelClass = 'block text-xs font-medium text-content-secondary mb-1'
const create = async () => {
if (!name.trim()) return
setSaving(true)
try {
const trimmedCategory = category.trim()
const item = await addTodoItem(tripId, {
name: name.trim(), description: desc || null, priority,
due_date: dueDate || null, category: trimmedCategory || null,
assigned_user_id: assignedUserId,
} as any)
if (item?.id) onCreated(item.id)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
setSaving(false)
}
return (
{t('todo.newItem')}
setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && name.trim()) create() }}
style={{ width: '100%', fontSize: 15, fontWeight: 600, border: 'none', padding: '4px 0', background: 'transparent', color: 'var(--text-primary)', outline: 'none', fontFamily: 'inherit' }}
placeholder={t('todo.namePlaceholder')} />
{addingCategory ? (
setCategory(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
placeholder={t('todo.newCategory')}
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
/>
) : (
setCategory(String(v))}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c, label: c,
icon: ,
})),
...(category && !categories.includes(category) ? [{
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
icon: ,
}] : []),
]}
placeholder={t('todo.noCategory')}
size="sm"
/>
)}
{[0, 1, 2, 3].map(p => {
const cfg = PRIO_CONFIG[p]
const isActive = priority === p
return (
)
})}
setDueDate(v)} />
setAssignedUserId(v ? Number(v) : null)}
options={[
{ value: '', label: t('todo.unassigned'), icon: },
...members.map(m => ({
value: String(m.id), label: m.username,
icon: m.avatar ? (
) : (
{m.username.charAt(0).toUpperCase()}
),
})),
]}
placeholder={t('todo.unassigned')}
size="sm"
/>
)
}