mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
feat(todo): add To-Do list feature with 3-column layout
- New todo_items DB table with priority, due date, description, user assignment - Full CRUD API with WebSocket real-time sync - 3-column UI: sidebar filters (All, My Tasks, Overdue, Done, by Priority), task list with inline badges, and detail/create pane - Apple-inspired design with custom dropdowns, date picker, priority system (P1-P3) - Mobile responsive: icon-only sidebar, bottom-sheet modals for detail/create - Lists tab with sub-tabs (Packing List + To-Do), persisted selection - Addon renamed from "Packing List" to "Lists" - i18n keys for all 13 languages - UI polish: notification colors use system theme, mobile navbar cleanup, settings page responsive buttons
This commit is contained in:
@@ -96,7 +96,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
{t('notifications.title')}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{ background: '#6366f1', color: '#fff' }}>
|
||||
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -133,7 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<div className="w-5 h-5 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
|
||||
<div className="w-5 h-5 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center gap-2">
|
||||
@@ -154,7 +154,7 @@ export default function InAppNotificationBell(): React.ReactElement {
|
||||
className="w-full py-2.5 text-xs font-medium transition-colors flex-shrink-0"
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-secondary)',
|
||||
color: '#6366f1',
|
||||
color: 'var(--text-primary)',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{tripTitle && (
|
||||
<>
|
||||
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
|
||||
<span className="text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="hidden sm:inline text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
|
||||
{tripTitle}
|
||||
</span>
|
||||
</>
|
||||
@@ -155,17 +155,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) */}
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
||||
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0"
|
||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* Notification bell */}
|
||||
{user && <InAppNotificationBell />}
|
||||
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
|
||||
{user && tripId && <InAppNotificationBell />}
|
||||
{user && !tripId && <span className="hidden sm:block"><InAppNotificationBell /></span>}
|
||||
|
||||
{/* User menu */}
|
||||
{user && (
|
||||
|
||||
@@ -59,10 +59,6 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
borderBottom: '1px solid var(--border-secondary)',
|
||||
}}
|
||||
>
|
||||
{/* Unread dot */}
|
||||
{!notification.is_read && (
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full" style={{ background: '#6366f1' }} />
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 items-start">
|
||||
{/* Sender avatar */}
|
||||
@@ -102,7 +98,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
title={t('notifications.markRead')}
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = '#6366f1' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
@@ -134,7 +130,7 @@ export default function InAppNotificationItem({ notification, onClose }: Notific
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: notification.response === 'positive'
|
||||
? '#6366f1'
|
||||
? 'var(--text-primary)'
|
||||
: notification.response === 'negative'
|
||||
? (dark ? '#27272a' : '#f1f5f9')
|
||||
: (dark ? '#27272a' : '#f1f5f9'),
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
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<number, { label: string; color: string }> = {
|
||||
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 }: { tripId: number; items: TodoItem[] }) {
|
||||
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<FilterType>('all')
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [isAddingNew, setIsAddingNew] = useState(false)
|
||||
const [sortByPrio, setSortByPrio] = useState(false)
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
const [currentUserId, setCurrentUserId] = useState<number | null>(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<string>()
|
||||
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 : '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 }) => (
|
||||
<button onClick={() => setFilter(id as FilterType)}
|
||||
title={isMobile ? label : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
||||
border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
|
||||
background: filter === id ? 'var(--bg-hover)' : 'transparent',
|
||||
color: filter === id ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: filter === id ? 600 : 400, transition: 'all 0.1s',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={e => { if (filter !== id) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (filter !== id) e.currentTarget.style.background = 'transparent' }}>
|
||||
{color ? (
|
||||
<span style={{ width: isMobile ? 12 : 10, height: isMobile ? 12 : 10, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
||||
) : (
|
||||
<Icon size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
)}
|
||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{label}</span>}
|
||||
{!isMobile && count > 0 && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', background: 'var(--bg-hover)', borderRadius: 10, padding: '1px 7px', minWidth: 20, textAlign: 'center' }}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{isMobile && count > 0 && (
|
||||
<span style={{ position: 'absolute', top: 2, right: 2, fontSize: 8, fontWeight: 700, color: 'var(--bg-primary)', background: 'var(--text-faint)', borderRadius: '50%', width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
// 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 (
|
||||
<div style={{ display: 'flex', height: 'calc(100vh - 180px)', minHeight: 400 }}>
|
||||
|
||||
{/* ── Left Sidebar ── */}
|
||||
<div style={{
|
||||
width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)',
|
||||
padding: isMobile ? '12px 6px' : '16px 10px', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
|
||||
transition: 'width 0.2s',
|
||||
}}>
|
||||
{/* Progress Card */}
|
||||
{!isMobile && <div style={{
|
||||
margin: '0 6px 12px', padding: '14px 14px 12px', borderRadius: 14,
|
||||
background: 'var(--bg-hover)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1, letterSpacing: '-0.02em' }}>
|
||||
{totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: 'var(--border-faint)', borderRadius: 2, overflow: 'hidden', marginBottom: 6 }}>
|
||||
<div style={{ height: '100%', width: totalCount > 0 ? `${Math.round((doneCount / totalCount) * 100)}%` : '0%', background: '#22c55e', borderRadius: 2, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)' }}>
|
||||
{doneCount} / {totalCount} {t('todo.completed')}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Smart filters */}
|
||||
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('todo.sidebar.tasks')}
|
||||
</div>}
|
||||
<SidebarItem id="all" icon={Inbox} label={t('todo.filter.all')} count={items.filter(i => !i.checked).length} />
|
||||
<SidebarItem id="my" icon={User} label={t('todo.filter.my')} count={myCount} />
|
||||
<SidebarItem id="overdue" icon={AlertCircle} label={t('todo.filter.overdue')} count={overdueCount} />
|
||||
<SidebarItem id="done" icon={CheckCheck} label={t('todo.filter.done')} count={doneCount} />
|
||||
|
||||
{/* Sort by priority */}
|
||||
<button onClick={() => setSortByPrio(v => !v)}
|
||||
title={isMobile ? t('todo.sortByPrio') : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
||||
border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13,
|
||||
background: sortByPrio ? '#f59e0b12' : 'transparent',
|
||||
color: sortByPrio ? '#f59e0b' : 'var(--text-secondary)',
|
||||
fontWeight: sortByPrio ? 600 : 400, transition: 'all 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!sortByPrio) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!sortByPrio) e.currentTarget.style.background = 'transparent' }}>
|
||||
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.sortByPrio')}</span>}
|
||||
</button>
|
||||
|
||||
{/* Categories */}
|
||||
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('todo.sidebar.categories')}
|
||||
</div>}
|
||||
{isMobile && <div style={{ height: 1, background: 'var(--border-faint)', margin: '8px 4px' }} />}
|
||||
{categories.map(cat => (
|
||||
<SidebarItem key={cat} id={cat} icon={null} label={cat} count={catCount(cat)} color={katColor(cat, categories)} />
|
||||
))}
|
||||
|
||||
{canEdit && (
|
||||
addingCategory && !isMobile ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '4px 12px' }}>
|
||||
<input autoFocus value={newCategoryName} onChange={e => 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 }} />
|
||||
<button onClick={addCategory} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#22c55e', padding: 2 }}><Check size={13} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setAddingCategory(true)}
|
||||
title={isMobile ? t('todo.addCategory') : undefined}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start', gap: isMobile ? 0 : 6, padding: isMobile ? '8px 0' : '7px 12px', fontSize: 12, color: 'var(--text-faint)', background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', width: '100%', textAlign: 'left' }}>
|
||||
<Plus size={isMobile ? 18 : 13} /> {!isMobile && t('todo.addCategory')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Middle: Task List ── */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 700, color: 'var(--text-primary)', letterSpacing: '-0.02em' }}>
|
||||
{filterTitle}
|
||||
</h2>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)', background: 'var(--bg-hover)', borderRadius: 6, padding: '2px 8px', fontWeight: 600 }}>
|
||||
{filtered.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add task */}
|
||||
{canEdit && (
|
||||
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<button
|
||||
onClick={() => { setSelectedId(null); setIsAddingNew(true) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '9px 16px', borderRadius: 8,
|
||||
background: isAddingNew ? 'var(--text-primary)' : 'var(--bg-hover)',
|
||||
color: isAddingNew ? 'var(--bg-primary)' : 'var(--text-primary)',
|
||||
border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 13, fontWeight: 600, transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'var(--bg-primary)'; e.currentTarget.style.borderColor = 'var(--text-primary)' } }}
|
||||
onMouseLeave={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' } }}>
|
||||
<Plus size={14} />
|
||||
{t('todo.addItem')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
|
||||
{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 (
|
||||
<div key={item.id}
|
||||
onClick={() => { 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 */}
|
||||
<button onClick={e => { e.stopPropagation(); canEdit && toggleTodoItem(tripId, item.id, !done) }}
|
||||
style={{ background: 'none', border: 'none', cursor: canEdit ? 'pointer' : 'default', padding: 0, flexShrink: 0,
|
||||
color: done ? '#22c55e' : 'var(--border-primary)' }}>
|
||||
{done ? <CheckSquare size={18} /> : <Square size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: 14, color: done ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
textDecoration: done ? 'line-through' : 'none', lineHeight: 1.4,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{item.name}
|
||||
</div>
|
||||
{/* Description preview */}
|
||||
{item.description && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.4 }}>
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
{/* Inline badges */}
|
||||
{(item.priority || item.due_date || catColor || assignedUser) && (
|
||||
<div style={{ display: 'flex', gap: 5, marginTop: 5, flexWrap: 'wrap' }}>
|
||||
{item.priority > 0 && PRIO_CONFIG[item.priority] && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 600,
|
||||
color: PRIO_CONFIG[item.priority].color,
|
||||
background: `${PRIO_CONFIG[item.priority].color}10`,
|
||||
border: `1px solid ${PRIO_CONFIG[item.priority].color}25`,
|
||||
}}>
|
||||
<Flag size={9} />{PRIO_CONFIG[item.priority].label}
|
||||
</span>
|
||||
)}
|
||||
{item.due_date && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
|
||||
color: isOverdue ? '#ef4444' : 'var(--text-secondary)',
|
||||
background: isOverdue ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)',
|
||||
border: `1px solid ${isOverdue ? 'rgba(239,68,68,0.15)' : 'var(--border-faint)'}`,
|
||||
}}>
|
||||
<Calendar size={9} />{formatDate(item.due_date)}
|
||||
</span>
|
||||
)}
|
||||
{catColor && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
|
||||
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: catColor, flexShrink: 0 }} />
|
||||
{item.category}
|
||||
</span>
|
||||
)}
|
||||
{assignedUser && (
|
||||
<span style={{
|
||||
fontSize: 10, display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
padding: '2px 7px', borderRadius: 5, fontWeight: 500,
|
||||
color: 'var(--text-secondary)', background: 'var(--bg-hover)',
|
||||
border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{assignedUser.avatar ? (
|
||||
<img src={`/uploads/avatars/${assignedUser.avatar}`} style={{ width: 13, height: 13, borderRadius: '50%', objectFit: 'cover' }} alt="" />
|
||||
) : (
|
||||
<span style={{ width: 13, height: 13, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, color: 'var(--text-faint)', fontWeight: 700 }}>
|
||||
{assignedUser.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{assignedUser.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight size={16} color="var(--text-faint)" style={{ flexShrink: 0, opacity: 0.4 }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Detail Pane ── */}
|
||||
{selectedItem && !isAddingNew && !isMobile && (
|
||||
<DetailPane
|
||||
item={selectedItem}
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
{selectedItem && !isAddingNew && isMobile && (
|
||||
<div onClick={e => { 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' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { 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' } } }}>
|
||||
<DetailPane
|
||||
item={selectedItem}
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isAddingNew && !selectedItem && !isMobile && (
|
||||
<NewTaskPane
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
|
||||
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
|
||||
onClose={() => setIsAddingNew(false)}
|
||||
/>
|
||||
)}
|
||||
{isAddingNew && !selectedItem && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
|
||||
ref={el => { 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' } } }}>
|
||||
<NewTaskPane
|
||||
tripId={tripId}
|
||||
categories={categories}
|
||||
members={members}
|
||||
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
|
||||
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
|
||||
onClose={() => setIsAddingNew(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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<number | null>(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 : 'Error') }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteTodoItem(tripId, item.id)
|
||||
onClose()
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : '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 (
|
||||
<div style={{
|
||||
width: 320, flexShrink: 0, borderLeft: '1px solid var(--border-faint)',
|
||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-primary)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{t('todo.detail.title')}</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4 }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{/* Name */}
|
||||
<div>
|
||||
<input value={name} onChange={e => setName(e.target.value)} disabled={!canEdit}
|
||||
style={{ ...inputStyle, fontSize: 15, fontWeight: 600, border: 'none', padding: '4px 0', background: 'transparent' }}
|
||||
placeholder={t('todo.namePlaceholder')} />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.description')}</label>
|
||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} disabled={!canEdit} rows={4}
|
||||
placeholder={t('todo.descriptionPlaceholder')}
|
||||
style={{ ...inputStyle, resize: 'vertical', minHeight: 80 }} />
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.priority')}</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[0, 1, 2, 3].map(p => {
|
||||
const cfg = PRIO_CONFIG[p]
|
||||
const isActive = priority === p
|
||||
return (
|
||||
<button key={p} onClick={() => canEdit && setPriority(p)}
|
||||
style={{
|
||||
flex: 1, padding: '6px 0', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: canEdit ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
border: `1px solid ${isActive && cfg ? cfg.color + '40' : 'var(--border-primary)'}`,
|
||||
background: isActive && cfg ? cfg.color + '12' : 'transparent',
|
||||
color: isActive && cfg ? cfg.color : isActive ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
transition: 'all 0.1s',
|
||||
}}>
|
||||
{cfg ? <><Flag size={10} />{cfg.label}</> : t('todo.detail.noPriority')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.category')}</label>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(v)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c,
|
||||
label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Due date */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.dueDate')}</label>
|
||||
<CustomDatePicker
|
||||
value={dueDate}
|
||||
onChange={v => setDueDate(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Assigned to */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.assignedTo')}</label>
|
||||
<CustomSelect
|
||||
value={String(assignedUserId ?? '')}
|
||||
onChange={v => setAssignedUserId(v ? Number(v) : null)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.unassigned'), icon: <User size={14} style={{ color: 'var(--text-faint)' }} /> },
|
||||
...members.map(m => ({
|
||||
value: String(m.id),
|
||||
label: m.username,
|
||||
icon: m.avatar ? (
|
||||
<img src={`/uploads/avatars/${m.avatar}`} style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' as const }} alt="" />
|
||||
) : (
|
||||
<span style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: 'var(--text-faint)', fontWeight: 600 }}>
|
||||
{m.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.unassigned')}
|
||||
size="sm"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
{canEdit && (
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 8 }}>
|
||||
<button onClick={handleDelete}
|
||||
style={{
|
||||
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
border: '1px solid var(--border-primary)', background: 'transparent', color: 'var(--text-secondary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
}}>
|
||||
<Trash2 size={13} />
|
||||
{t('todo.detail.delete')}
|
||||
</button>
|
||||
<button onClick={save} disabled={!hasChanges || saving}
|
||||
style={{
|
||||
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: hasChanges ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
border: 'none', background: hasChanges ? 'var(--text-primary)' : 'var(--border-faint)',
|
||||
color: hasChanges ? 'var(--bg-primary)' : 'var(--text-faint)',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{saving ? '...' : t('todo.detail.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 [assignedUserId, setAssignedUserId] = useState<number | null>(null)
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' }
|
||||
|
||||
const create = async () => {
|
||||
if (!name.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const item = await addTodoItem(tripId, {
|
||||
name: name.trim(), description: desc || null, priority,
|
||||
due_date: dueDate || null, category: category || null,
|
||||
assigned_user_id: assignedUserId,
|
||||
} as any)
|
||||
if (item?.id) onCreated(item.id)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: 320, flexShrink: 0, borderLeft: '1px solid var(--border-faint)',
|
||||
display: 'flex', flexDirection: 'column', background: 'var(--bg-primary)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>{t('todo.newItem')}</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4 }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<input autoFocus value={name} onChange={e => 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')} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.description')}</label>
|
||||
<textarea value={desc} onChange={e => setDesc(e.target.value)} rows={4}
|
||||
placeholder={t('todo.descriptionPlaceholder')}
|
||||
style={{ 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', resize: 'vertical', minHeight: 80 }} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.category')}</label>
|
||||
<CustomSelect
|
||||
value={category}
|
||||
onChange={v => setCategory(v)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.noCategory') },
|
||||
...categories.map(c => ({
|
||||
value: c, label: c,
|
||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.noCategory')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.priority')}</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{[0, 1, 2, 3].map(p => {
|
||||
const cfg = PRIO_CONFIG[p]
|
||||
const isActive = priority === p
|
||||
return (
|
||||
<button key={p} onClick={() => setPriority(p)}
|
||||
style={{
|
||||
flex: 1, padding: '6px 0', borderRadius: 6, fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
border: `1px solid ${isActive && cfg ? cfg.color + '40' : 'var(--border-primary)'}`,
|
||||
background: isActive && cfg ? cfg.color + '12' : 'transparent',
|
||||
color: isActive && cfg ? cfg.color : isActive ? 'var(--text-primary)' : 'var(--text-faint)',
|
||||
transition: 'all 0.1s',
|
||||
}}>
|
||||
{cfg ? <><Flag size={10} />{cfg.label}</> : t('todo.detail.noPriority')}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.dueDate')}</label>
|
||||
<CustomDatePicker value={dueDate} onChange={v => setDueDate(v)} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.assignedTo')}</label>
|
||||
<CustomSelect
|
||||
value={String(assignedUserId ?? '')}
|
||||
onChange={v => setAssignedUserId(v ? Number(v) : null)}
|
||||
options={[
|
||||
{ value: '', label: t('todo.unassigned'), icon: <User size={14} style={{ color: 'var(--text-faint)' }} /> },
|
||||
...members.map(m => ({
|
||||
value: String(m.id), label: m.username,
|
||||
icon: m.avatar ? (
|
||||
<img src={`/uploads/avatars/${m.avatar}`} style={{ width: 18, height: 18, borderRadius: '50%', objectFit: 'cover' as const }} alt="" />
|
||||
) : (
|
||||
<span style={{ width: 18, height: 18, borderRadius: '50%', background: 'var(--border-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 10, color: 'var(--text-faint)', fontWeight: 600 }}>
|
||||
{m.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
]}
|
||||
placeholder={t('todo.unassigned')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--border-faint)' }}>
|
||||
<button onClick={create} disabled={!name.trim() || saving}
|
||||
style={{
|
||||
width: '100%', padding: '9px 16px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: name.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||
border: 'none', background: name.trim() ? 'var(--text-primary)' : 'var(--border-faint)',
|
||||
color: name.trim() ? 'var(--bg-primary)' : 'var(--text-faint)', transition: 'all 0.15s',
|
||||
}}>
|
||||
{saving ? '...' : t('todo.detail.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user