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:
mauriceboe
2026-04-04 16:58:24 +02:00
parent 1ea0eb9965
commit 0b36427c09
31 changed files with 1732 additions and 102 deletions

View File

@@ -136,6 +136,16 @@ export const packingApi = {
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).then(r => r.data),
}
export const todoApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/todo/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/todo/${id}`).then(r => r.data),
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/todo/reorder`, { orderedIds }).then(r => r.data),
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/todo/category-assignees`).then(r => r.data),
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/todo/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
}
export const tagsApi = {
list: () => apiClient.get('/tags').then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),

View File

@@ -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)'}

View File

@@ -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 && (

View File

@@ -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'),

View File

@@ -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>
)
}

View File

@@ -513,8 +513,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'بروتوكول سياق النموذج لتكامل مساعد الذكاء الاصطناعي',
'admin.addons.catalog.packing.name': 'التعبئة',
'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'الميزانية',
'admin.addons.catalog.budget.description': 'تتبع النفقات وخطط ميزانية الرحلة',
'admin.addons.catalog.documents.name': 'المستندات',
@@ -741,6 +741,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'حجز',
'trip.tabs.packing': 'قائمة التجهيز',
'trip.tabs.packingShort': 'تجهيز',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'الميزانية',
'trip.tabs.files': 'الملفات',
'trip.loading': 'جارٍ تحميل الرحلة...',
@@ -1565,6 +1567,40 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.adminText': 'أرسل {actor} إشعاراً تجريبياً لجميع المسؤولين.',
'notifications.test.tripTitle': 'نشر {actor} في رحلتك',
'notifications.test.tripText': 'إشعار تجريبي للرحلة "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.detail.create': 'Create task',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.sortByPrio': 'Priority',
}
export default ar

View File

@@ -490,8 +490,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.subtitle': 'Ative ou desative recursos para personalizar sua experiência no TREK.',
'admin.addons.catalog.memories.name': 'Memórias',
'admin.addons.catalog.memories.description': 'Álbuns de fotos compartilhados em cada viagem',
'admin.addons.catalog.packing.name': 'Mala',
'admin.addons.catalog.packing.description': 'Listas para preparar a bagagem de cada viagem',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'Orçamento',
'admin.addons.catalog.budget.description': 'Acompanhe despesas e planeje o orçamento da viagem',
'admin.addons.catalog.documents.name': 'Documentos',
@@ -723,6 +723,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Reservas',
'trip.tabs.packing': 'Lista de mala',
'trip.tabs.packingShort': 'Mala',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'Orçamento',
'trip.tabs.files': 'Arquivos',
'trip.loading': 'Carregando viagem...',
@@ -1560,6 +1562,40 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.adminText': '{actor} enviou uma notificação de teste para todos os admins.',
'notifications.test.tripTitle': '{actor} postou na sua viagem',
'notifications.test.tripText': 'Notificação de teste para a viagem "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.detail.create': 'Create task',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.sortByPrio': 'Priority',
}
export default br

View File

@@ -490,8 +490,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.subtitle': 'Zapněte nebo vypněte funkce a přizpůsobte si TREK.',
'admin.addons.catalog.memories.name': 'Fotky (Immich)',
'admin.addons.catalog.memories.description': 'Sdílejte cestovní fotky přes vaši instanci Immich',
'admin.addons.catalog.packing.name': 'Balení',
'admin.addons.catalog.packing.description': 'Seznamy věcí pro přípravu na cestu',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'Rozpočet',
'admin.addons.catalog.budget.description': 'Sledování výdajů a plánování rozpočtu cesty',
'admin.addons.catalog.documents.name': 'Dokumenty',
@@ -739,6 +739,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Rez.',
'trip.tabs.packing': 'Seznam věcí',
'trip.tabs.packingShort': 'Balení',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'Rozpočet',
'trip.tabs.files': 'Soubory',
'trip.loading': 'Načítání cesty...',
@@ -1565,6 +1567,40 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.adminText': '{actor} odeslal testovací oznámení všem správcům.',
'notifications.test.tripTitle': '{actor} přispěl do vašeho výletu',
'notifications.test.tripText': 'Testovací oznámení pro výlet "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.detail.create': 'Create task',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.sortByPrio': 'Priority',
}
export default cs

View File

@@ -489,8 +489,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Aktiviere oder deaktiviere Funktionen, um TREK nach deinen Wünschen anzupassen.',
'admin.addons.catalog.packing.name': 'Packliste',
'admin.addons.catalog.packing.description': 'Checklisten zum Kofferpacken für jede Reise',
'admin.addons.catalog.packing.name': 'Listen',
'admin.addons.catalog.packing.description': 'Packlisten und To-Do-Aufgaben für deine Reisen',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Ausgaben verfolgen und Reisebudget planen',
'admin.addons.catalog.documents.name': 'Dokumente',
@@ -739,6 +739,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Buchung',
'trip.tabs.packing': 'Liste',
'trip.tabs.packingShort': 'Liste',
'trip.tabs.lists': 'Listen',
'trip.tabs.listsShort': 'Listen',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Dateien',
'trip.loading': 'Reise wird geladen...',
@@ -1562,6 +1564,40 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.adminText': '{actor} hat eine Testbenachrichtigung an alle Admins gesendet.',
'notifications.test.tripTitle': '{actor} hat in Ihrer Reise gepostet',
'notifications.test.tripText': 'Testbenachrichtigung für Reise "{trip}".',
// Todo
'todo.subtab.packing': 'Packliste',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'erledigt',
'todo.filter.all': 'Alle',
'todo.filter.open': 'Offen',
'todo.filter.done': 'Erledigt',
'todo.uncategorized': 'Ohne Kategorie',
'todo.namePlaceholder': 'Aufgabenname',
'todo.descriptionPlaceholder': 'Beschreibung (optional)',
'todo.unassigned': 'Nicht zugewiesen',
'todo.noCategory': 'Keine Kategorie',
'todo.hasDescription': 'Hat Beschreibung',
'todo.addItem': 'Neue Aufgabe hinzufügen...',
'todo.newCategory': 'Kategoriename',
'todo.addCategory': 'Kategorie hinzufügen',
'todo.newItem': 'Neue Aufgabe',
'todo.empty': 'Noch keine Aufgaben. Erstelle eine Aufgabe um loszulegen!',
'todo.filter.my': 'Meine Aufgaben',
'todo.filter.overdue': 'Überfällig',
'todo.sidebar.tasks': 'Aufgaben',
'todo.sidebar.categories': 'Kategorien',
'todo.detail.title': 'Aufgabe',
'todo.detail.description': 'Beschreibung',
'todo.detail.category': 'Kategorie',
'todo.detail.dueDate': 'Fällig am',
'todo.detail.assignedTo': 'Zuständig',
'todo.detail.delete': 'Löschen',
'todo.detail.save': 'Speichern',
'todo.sortByPrio': 'Priorität',
'todo.detail.priority': 'Priorität',
'todo.detail.noPriority': 'Keine',
'todo.detail.create': 'Aufgabe erstellen',
}
export default de

View File

@@ -489,8 +489,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Addons',
'admin.addons.title': 'Addons',
'admin.addons.subtitle': 'Enable or disable features to customize your TREK experience.',
'admin.addons.catalog.packing.name': 'Packing',
'admin.addons.catalog.packing.description': 'Checklists to prepare your luggage for each trip',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Track expenses and plan your trip budget',
'admin.addons.catalog.documents.name': 'Documents',
@@ -736,6 +736,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Book',
'trip.tabs.packing': 'Packing List',
'trip.tabs.packingShort': 'Packing',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Files',
'trip.loading': 'Loading trip...',
@@ -1562,6 +1564,40 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.adminText': '{actor} sent a test notification to all admins.',
'notifications.test.tripTitle': '{actor} posted in your trip',
'notifications.test.tripText': 'Test notification for trip "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.sortByPrio': 'Priority',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.detail.create': 'Create task',
}
export default en

View File

@@ -715,6 +715,8 @@ const es: Record<string, string> = {
'trip.tabs.reservationsShort': 'Reservas',
'trip.tabs.packing': 'Lista de equipaje',
'trip.tabs.packingShort': 'Equipaje',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'Presupuesto',
'trip.tabs.files': 'Archivos',
'trip.loading': 'Cargando viaje...',
@@ -1184,8 +1186,8 @@ const es: Record<string, string> = {
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Protocolo de contexto de modelo para integración con asistentes de IA',
'admin.addons.catalog.packing.name': 'Equipaje',
'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'Presupuesto',
'admin.addons.catalog.budget.description': 'Controla los gastos y planifica el presupuesto del viaje',
'admin.addons.catalog.documents.name': 'Documentos',
@@ -1567,6 +1569,40 @@ const es: Record<string, string> = {
'notifications.test.adminText': '{actor} envió una notificación de prueba a todos los administradores.',
'notifications.test.tripTitle': '{actor} publicó en tu viaje',
'notifications.test.tripText': 'Notificación de prueba para el viaje "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.detail.create': 'Create task',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.sortByPrio': 'Priority',
}
export default es

View File

@@ -491,8 +491,8 @@ const fr: Record<string, string> = {
'admin.addons.catalog.memories.description': 'Partagez vos photos de voyage via votre instance Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Protocole de contexte de modèle pour l\'intégration d\'assistants IA',
'admin.addons.catalog.packing.name': 'Bagages',
'admin.addons.catalog.packing.description': 'Listes de contrôle pour préparer vos bagages pour chaque voyage',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Suivez les dépenses et planifiez votre budget de voyage',
'admin.addons.catalog.documents.name': 'Documents',
@@ -738,6 +738,8 @@ const fr: Record<string, string> = {
'trip.tabs.reservationsShort': 'Résa',
'trip.tabs.packing': 'Liste de bagages',
'trip.tabs.packingShort': 'Bagages',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Fichiers',
'trip.loading': 'Chargement du voyage…',
@@ -1561,6 +1563,40 @@ const fr: Record<string, string> = {
'notifications.test.adminText': '{actor} a envoyé une notification de test à tous les admins.',
'notifications.test.tripTitle': '{actor} a publié dans votre voyage',
'notifications.test.tripText': 'Notification de test pour le voyage "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.detail.create': 'Create task',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.sortByPrio': 'Priority',
}
export default fr

View File

@@ -488,8 +488,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Bővítmények',
'admin.addons.title': 'Bővítmények',
'admin.addons.subtitle': 'Funkciók engedélyezése vagy letiltása a TREK testreszabásához.',
'admin.addons.catalog.packing.name': 'Csomagolás',
'admin.addons.catalog.packing.description': 'Ellenőrzőlisták a poggyász előkészítéséhez minden utazáshoz',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'Költségvetés',
'admin.addons.catalog.budget.description': 'Kiadások nyomon követése és az utazási költségvetés tervezése',
'admin.addons.catalog.documents.name': 'Dokumentumok',
@@ -739,6 +739,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Foglalás',
'trip.tabs.packing': 'Csomagolási lista',
'trip.tabs.packingShort': 'Csomag',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'Költségvetés',
'trip.tabs.files': 'Fájlok',
'trip.loading': 'Utazás betöltése...',
@@ -1562,6 +1564,40 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.adminText': '{actor} teszt értesítést küldött az összes adminisztrátornak.',
'notifications.test.tripTitle': '{actor} üzenetet küldött az utazásodba',
'notifications.test.tripText': 'Teszt értesítés a(z) "{trip}" utazáshoz.',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.detail.create': 'Create task',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.sortByPrio': 'Priority',
}
export default hu

View File

@@ -487,8 +487,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Moduli',
'admin.addons.title': 'Moduli',
'admin.addons.subtitle': 'Abilita o disabilita le funzionalità per personalizzare la tua esperienza TREK.',
'admin.addons.catalog.packing.name': 'Lista valigia',
'admin.addons.catalog.packing.description': 'Checklist per preparare la valigia per ogni viaggio',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Tieni traccia delle spese e pianifica il budget del tuo viaggio',
'admin.addons.catalog.documents.name': 'Documenti',
@@ -739,6 +739,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Pren.',
'trip.tabs.packing': 'Lista valigia',
'trip.tabs.packingShort': 'Valigia',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'File',
'trip.loading': 'Caricamento viaggio...',
@@ -1562,6 +1564,40 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.adminText': '{actor} ha inviato una notifica di test a tutti gli amministratori.',
'notifications.test.tripTitle': '{actor} ha pubblicato nel tuo viaggio',
'notifications.test.tripText': 'Notifica di test per il viaggio "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.detail.create': 'Create task',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.sortByPrio': 'Priority',
}
export default it

View File

@@ -492,8 +492,8 @@ const nl: Record<string, string> = {
'admin.addons.catalog.memories.description': 'Deel reisfoto\'s via je Immich-instantie',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Model Context Protocol voor AI-assistent integratie',
'admin.addons.catalog.packing.name': 'Inpakken',
'admin.addons.catalog.packing.description': 'Checklists om je bagage voor elke reis voor te bereiden',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'Budget',
'admin.addons.catalog.budget.description': 'Houd uitgaven bij en plan je reisbudget',
'admin.addons.catalog.documents.name': 'Documenten',
@@ -738,6 +738,8 @@ const nl: Record<string, string> = {
'trip.tabs.reservationsShort': 'Boek',
'trip.tabs.packing': 'Paklijst',
'trip.tabs.packingShort': 'Inpakken',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Bestanden',
'trip.loading': 'Reis laden...',
@@ -1561,6 +1563,40 @@ const nl: Record<string, string> = {
'notifications.test.adminText': '{actor} heeft een testmelding naar alle admins gestuurd.',
'notifications.test.tripTitle': '{actor} heeft gepost in uw reis',
'notifications.test.tripText': 'Testmelding voor reis "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.detail.create': 'Create task',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.sortByPrio': 'Priority',
}
export default nl

View File

@@ -455,8 +455,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.addons': 'Dodatki',
'admin.addons.title': 'Dodatki',
'admin.addons.subtitle': 'Włączaj lub wyłączaj funkcje, aby dostosować swoje doświadczenie w TREK.',
'admin.addons.catalog.packing.name': 'Pakowanie',
'admin.addons.catalog.packing.description': 'Listy do przygotowania bagażu na każdą podróż',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'Budżet',
'admin.addons.catalog.budget.description': 'Śledź wydatki i planuj budżet podróży',
'admin.addons.catalog.documents.name': 'Dokumenty',
@@ -701,6 +701,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.reservationsShort': 'Rezerwacje',
'trip.tabs.packing': 'Lista pakowania',
'trip.tabs.packingShort': 'Pakowanie',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'Budżet',
'trip.tabs.files': 'Pliki',
'trip.loading': 'Ładowanie podróży...',
@@ -1554,6 +1556,40 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'notifications.test.adminText': '{actor} wysłał testowe powiadomienie.',
'notifications.test.tripTitle': '{actor} opublikował w Twojej podróży',
'notifications.test.tripText': 'Testowe powiadomienie dla podróży "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.detail.create': 'Create task',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.sortByPrio': 'Priority',
}
export default pl

View File

@@ -492,8 +492,8 @@ const ru: Record<string, string> = {
'admin.addons.catalog.memories.description': 'Делитесь фотографиями из поездок через Immich',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': 'Протокол контекста модели для интеграции с ИИ-ассистентами',
'admin.addons.catalog.packing.name': 'Сборы',
'admin.addons.catalog.packing.description': 'Чек-листы для подготовки багажа к каждой поездке',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': 'Бюджет',
'admin.addons.catalog.budget.description': 'Отслеживайте расходы и планируйте бюджет поездки',
'admin.addons.catalog.documents.name': 'Документы',
@@ -738,6 +738,8 @@ const ru: Record<string, string> = {
'trip.tabs.reservationsShort': 'Брони',
'trip.tabs.packing': 'Список вещей',
'trip.tabs.packingShort': 'Вещи',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': 'Бюджет',
'trip.tabs.files': 'Файлы',
'trip.loading': 'Загрузка поездки...',
@@ -1561,6 +1563,40 @@ const ru: Record<string, string> = {
'notifications.test.adminText': '{actor} отправил тестовое уведомление всем администраторам.',
'notifications.test.tripTitle': '{actor} написал в вашей поездке',
'notifications.test.tripText': 'Тестовое уведомление для поездки "{trip}".',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.detail.create': 'Create task',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.sortByPrio': 'Priority',
}
export default ru

View File

@@ -492,8 +492,8 @@ const zh: Record<string, string> = {
'admin.addons.catalog.memories.description': '通过 Immich 实例分享旅行照片',
'admin.addons.catalog.mcp.name': 'MCP',
'admin.addons.catalog.mcp.description': '用于 AI 助手集成的模型上下文协议',
'admin.addons.catalog.packing.name': '行李',
'admin.addons.catalog.packing.description': '每次旅行的行李准备清单',
'admin.addons.catalog.packing.name': 'Lists',
'admin.addons.catalog.packing.description': 'Packing lists and to-do tasks for your trips',
'admin.addons.catalog.budget.name': '预算',
'admin.addons.catalog.budget.description': '跟踪支出并规划旅行预算',
'admin.addons.catalog.documents.name': '文档',
@@ -738,6 +738,8 @@ const zh: Record<string, string> = {
'trip.tabs.reservationsShort': '预订',
'trip.tabs.packing': '行李清单',
'trip.tabs.packingShort': '行李',
'trip.tabs.lists': 'Lists',
'trip.tabs.listsShort': 'Lists',
'trip.tabs.budget': '预算',
'trip.tabs.files': '文件',
'trip.loading': '加载旅行中...',
@@ -1561,6 +1563,40 @@ const zh: Record<string, string> = {
'notifications.test.adminText': '{actor} 向所有管理员发送了测试通知。',
'notifications.test.tripTitle': '{actor} 在您的行程中发帖',
'notifications.test.tripText': '行程"{trip}"的测试通知。',
// Todo
'todo.subtab.packing': 'Packing List',
'todo.subtab.todo': 'To-Do',
'todo.completed': 'completed',
'todo.filter.all': 'All',
'todo.filter.open': 'Open',
'todo.filter.done': 'Done',
'todo.uncategorized': 'Uncategorized',
'todo.namePlaceholder': 'Task name',
'todo.descriptionPlaceholder': 'Description (optional)',
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
'todo.empty': 'No tasks yet. Add a task to get started!',
'todo.filter.my': 'My Tasks',
'todo.filter.overdue': 'Overdue',
'todo.sidebar.tasks': 'Tasks',
'todo.sidebar.categories': 'Categories',
'todo.detail.title': 'Task',
'todo.detail.description': 'Description',
'todo.detail.category': 'Category',
'todo.detail.dueDate': 'Due date',
'todo.detail.assignedTo': 'Assigned to',
'todo.detail.delete': 'Delete',
'todo.detail.save': 'Save changes',
'todo.detail.create': 'Create task',
'todo.detail.priority': 'Priority',
'todo.detail.noPriority': 'None',
'todo.sortByPrio': 'Priority',
}
export default zh

View File

@@ -54,8 +54,8 @@ export default function InAppNotificationsPage(): React.ReactElement {
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
{t('notifications.title')}
{unreadCount > 0 && (
<span className="ml-2 px-2 py-0.5 rounded-full text-xs font-medium"
style={{ background: '#6366f1', color: '#fff' }}>
<span className="ml-2 px-2 py-0.5 rounded-full text-xs font-medium align-middle inline-flex items-center justify-center"
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
{unreadCount}
</span>
)}
@@ -97,8 +97,8 @@ export default function InAppNotificationsPage(): React.ReactElement {
onClick={() => setUnreadOnly(false)}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{
background: !unreadOnly ? '#6366f1' : 'var(--bg-hover)',
color: !unreadOnly ? '#fff' : 'var(--text-secondary)',
background: !unreadOnly ? 'var(--text-primary)' : 'var(--bg-hover)',
color: !unreadOnly ? 'var(--bg-primary)' : 'var(--text-secondary)',
}}
>
{t('notifications.all')}
@@ -107,8 +107,8 @@ export default function InAppNotificationsPage(): React.ReactElement {
onClick={() => setUnreadOnly(true)}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{
background: unreadOnly ? '#6366f1' : 'var(--bg-hover)',
color: unreadOnly ? '#fff' : 'var(--text-secondary)',
background: unreadOnly ? 'var(--text-primary)' : 'var(--bg-hover)',
color: unreadOnly ? 'var(--bg-primary)' : 'var(--text-secondary)',
}}
>
{t('notifications.unreadOnly')}
@@ -122,7 +122,7 @@ export default function InAppNotificationsPage(): React.ReactElement {
>
{isLoading && displayed.length === 0 ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />
<div className="w-6 h-6 border-2 border-slate-200 border-t-current rounded-full animate-spin" />
</div>
) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center gap-3">
@@ -139,7 +139,7 @@ export default function InAppNotificationsPage(): React.ReactElement {
{/* Infinite scroll trigger */}
{hasMore && (
<div ref={loaderRef} className="flex items-center justify-center py-4">
{isLoading && <div className="w-5 h-5 border-2 border-slate-200 border-t-indigo-500 rounded-full animate-spin" />}
{isLoading && <div className="w-5 h-5 border-2 border-slate-200 border-t-current rounded-full animate-spin" />}
</div>
)}
</div>

View File

@@ -550,7 +550,7 @@ export default function SettingsPage(): React.ReactElement {
{/* Dark Mode Toggle */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
<div className="flex gap-3">
<div className="flex gap-3" style={{ flexWrap: 'wrap' }}>
{[
{ value: 'light', label: t('settings.light'), icon: Sun },
{ value: 'dark', label: t('settings.dark'), icon: Moon },
@@ -567,8 +567,8 @@ export default function SettingsPage(): React.ReactElement {
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
padding: '10px 14px', borderRadius: 10, cursor: 'pointer', flex: '1 1 0', justifyContent: 'center', minWidth: 0,
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: isActive ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: isActive ? 'var(--bg-hover)' : 'var(--bg-card)',
@@ -1275,7 +1275,8 @@ export default function SettingsPage(): React.ReactElement {
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
>
{saving.profile ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{t('settings.saveProfile')}
<span className="hidden sm:inline">{t('settings.saveProfile')}</span>
<span className="sm:hidden">{t('common.save')}</span>
</button>
<button
onClick={async () => {
@@ -1295,7 +1296,8 @@ export default function SettingsPage(): React.ReactElement {
style={{ border: '1px solid #fecaca' }}
>
<Trash2 size={14} />
{t('settings.deleteAccount')}
<span className="hidden sm:inline">{t('settings.deleteAccount')}</span>
<span className="sm:hidden">{t('common.delete')}</span>
</button>
</div>
</Section>

View File

@@ -17,6 +17,7 @@ import { ReservationModal } from '../components/Planner/ReservationModal'
import MemoriesPanel from '../components/Memories/MemoriesPanel'
import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel'
import TodoListPanel from '../components/Todo/TodoListPanel'
import FileManager from '../components/Files/FileManager'
import BudgetPanel from '../components/Budget/BudgetPanel'
import CollabPanel from '../components/Collab/CollabPanel'
@@ -31,7 +32,40 @@ import { useTripWebSocket } from '../hooks/useTripWebSocket'
import { useRouteCalculation } from '../hooks/useRouteCalculation'
import { usePlaceSelection } from '../hooks/usePlaceSelection'
import { usePlannerHistory } from '../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../types'
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
import { ListTodo } from 'lucide-react'
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
return (sessionStorage.getItem(`trip-lists-subtab-${tripId}`) as 'packing' | 'todo') || 'packing'
})
const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) }
const { t } = useTranslation()
return (
<div>
<div style={{ display: 'flex', gap: 4, padding: '4px 16px 0', borderBottom: '1px solid var(--border-faint)', marginBottom: 8 }}>
{([
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck },
{ id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo },
]).map(tab => (
<button key={tab.id} onClick={() => setSubTabPersist(tab.id)}
style={{
display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, fontWeight: 500, padding: '8px 14px',
border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: 'none',
color: subTab === tab.id ? 'var(--text-primary)' : 'var(--text-faint)',
borderBottom: subTab === tab.id ? '2px solid var(--text-primary)' : '2px solid transparent',
marginBottom: -1, transition: 'color 0.15s',
}}>
<tab.icon size={14} />
{tab.label}
</button>
))}
</div>
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} />}
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} />}
</div>
)
}
export default function TripPlannerPage(): React.ReactElement | null {
const { id: tripId } = useParams<{ id: string }>()
@@ -44,6 +78,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const places = useTripStore(s => s.places)
const assignments = useTripStore(s => s.assignments)
const packingItems = useTripStore(s => s.packingItems)
const todoItems = useTripStore(s => s.todoItems)
const categories = useTripStore(s => s.categories)
const reservations = useTripStore(s => s.reservations)
const budgetItems = useTripStore(s => s.budgetItems)
@@ -88,7 +123,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
const TRIP_TABS = [
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort'), icon: PackageCheck }] : []),
...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title'), icon: Camera }] : []),
@@ -861,9 +896,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
)}
{activeTab === 'packliste' && (
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1200, margin: '0 auto', width: '100%', padding: '8px 0' }}>
<PackingListPanel tripId={tripId} items={packingItems} />
{activeTab === 'listen' && (
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0' }}>
<ListsContainer tripId={tripId} packingItems={packingItems} todoItems={todoItems} />
</div>
)}

View File

@@ -1,6 +1,6 @@
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Assignment, Place, Day, DayNote, PackingItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
import type { Assignment, Place, Day, DayNote, PackingItem, TodoItem, BudgetItem, BudgetMember, Reservation, Trip, TripFile, WebSocketEvent } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
@@ -175,6 +175,19 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void {
packingItems: state.packingItems.filter(i => i.id !== payload.itemId),
}
// Todo
case 'todo:created':
if (state.todoItems.some(i => i.id === (payload.item as TodoItem).id)) return {}
return { todoItems: [...state.todoItems, payload.item as TodoItem] }
case 'todo:updated':
return {
todoItems: state.todoItems.map(i => i.id === (payload.item as TodoItem).id ? payload.item as TodoItem : i),
}
case 'todo:deleted':
return {
todoItems: state.todoItems.filter(i => i.id !== payload.itemId),
}
// Budget
case 'budget:created':
if (state.budgetItems.some(i => i.id === (payload.item as BudgetItem).id)) return {}

View File

@@ -0,0 +1,67 @@
import { todoApi } from '../../api/client'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { TodoItem } from '../../types'
import { getApiErrorMessage } from '../../types'
type SetState = StoreApi<TripStoreState>['setState']
type GetState = StoreApi<TripStoreState>['getState']
export interface TodoSlice {
addTodoItem: (tripId: number | string, data: Partial<TodoItem>) => Promise<TodoItem>
updateTodoItem: (tripId: number | string, id: number, data: Partial<TodoItem>) => Promise<TodoItem>
deleteTodoItem: (tripId: number | string, id: number) => Promise<void>
toggleTodoItem: (tripId: number | string, id: number, checked: boolean) => Promise<void>
}
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
addTodoItem: async (tripId, data) => {
try {
const result = await todoApi.create(tripId, data)
set(state => ({ todoItems: [...state.todoItems, result.item] }))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error adding todo'))
}
},
updateTodoItem: async (tripId, id, data) => {
try {
const result = await todoApi.update(tripId, id, data)
set(state => ({
todoItems: state.todoItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating todo'))
}
},
deleteTodoItem: async (tripId, id) => {
const prev = get().todoItems
set(state => ({ todoItems: state.todoItems.filter(item => item.id !== id) }))
try {
await todoApi.delete(tripId, id)
} catch (err: unknown) {
set({ todoItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting todo'))
}
},
toggleTodoItem: async (tripId, id, checked) => {
set(state => ({
todoItems: state.todoItems.map(item =>
item.id === id ? { ...item, checked: checked ? 1 : 0 } : item
)
}))
try {
await todoApi.update(tripId, id, { checked })
} catch {
set(state => ({
todoItems: state.todoItems.map(item =>
item.id === id ? { ...item, checked: checked ? 0 : 1 } : item
)
}))
}
},
})

View File

@@ -1,16 +1,17 @@
import { create } from 'zustand'
import type { StoreApi } from 'zustand'
import { tripsApi, daysApi, placesApi, packingApi, tagsApi, categoriesApi } from '../api/client'
import { tripsApi, daysApi, placesApi, packingApi, todoApi, tagsApi, categoriesApi } from '../api/client'
import { createPlacesSlice } from './slices/placesSlice'
import { createAssignmentsSlice } from './slices/assignmentsSlice'
import { createDayNotesSlice } from './slices/dayNotesSlice'
import { createPackingSlice } from './slices/packingSlice'
import { createTodoSlice } from './slices/todoSlice'
import { createBudgetSlice } from './slices/budgetSlice'
import { createReservationsSlice } from './slices/reservationsSlice'
import { createFilesSlice } from './slices/filesSlice'
import { handleRemoteEvent } from './slices/remoteEventHandler'
import type {
Trip, Day, Place, Assignment, DayNote, PackingItem,
Trip, Day, Place, Assignment, DayNote, PackingItem, TodoItem,
Tag, Category, BudgetItem, TripFile, Reservation,
AssignmentsMap, DayNotesMap, WebSocketEvent,
} from '../types'
@@ -19,6 +20,7 @@ import type { PlacesSlice } from './slices/placesSlice'
import type { AssignmentsSlice } from './slices/assignmentsSlice'
import type { DayNotesSlice } from './slices/dayNotesSlice'
import type { PackingSlice } from './slices/packingSlice'
import type { TodoSlice } from './slices/todoSlice'
import type { BudgetSlice } from './slices/budgetSlice'
import type { ReservationsSlice } from './slices/reservationsSlice'
import type { FilesSlice } from './slices/filesSlice'
@@ -28,6 +30,7 @@ export interface TripStoreState
AssignmentsSlice,
DayNotesSlice,
PackingSlice,
TodoSlice,
BudgetSlice,
ReservationsSlice,
FilesSlice {
@@ -37,6 +40,7 @@ export interface TripStoreState
assignments: AssignmentsMap
dayNotes: DayNotesMap
packingItems: PackingItem[]
todoItems: TodoItem[]
tags: Tag[]
categories: Category[]
budgetItems: BudgetItem[]
@@ -62,6 +66,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
assignments: {},
dayNotes: {},
packingItems: [],
todoItems: [],
tags: [],
categories: [],
budgetItems: [],
@@ -78,11 +83,12 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
loadTrip: async (tripId: number | string) => {
set({ isLoading: true, error: null })
try {
const [tripData, daysData, placesData, packingData, tagsData, categoriesData] = await Promise.all([
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
tripsApi.get(tripId),
daysApi.list(tripId),
placesApi.list(tripId),
packingApi.list(tripId),
todoApi.list(tripId),
tagsApi.list(),
categoriesApi.list(),
])
@@ -101,6 +107,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
assignments: assignmentsMap,
dayNotes: dayNotesMap,
packingItems: packingData.items,
todoItems: todoData.items,
tags: tagsData.tags,
categories: categoriesData.categories,
isLoading: false,
@@ -169,6 +176,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
...createAssignmentsSlice(set, get),
...createDayNotesSlice(set, get),
...createPackingSlice(set, get),
...createTodoSlice(set, get),
...createBudgetSlice(set, get),
...createReservationsSlice(set, get),
...createFilesSlice(set, get),

View File

@@ -86,6 +86,19 @@ export interface PackingItem {
quantity: number
}
export interface TodoItem {
id: number
trip_id: number
name: string
category: string | null
checked: number
sort_order: number
due_date: string | null
description: string | null
assigned_user_id: number | null
priority: number
}
export interface Tag {
id: number
name: string

View File

@@ -1224,9 +1224,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1241,9 +1238,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1258,9 +1252,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1275,9 +1266,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1292,9 +1280,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1309,9 +1294,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1326,9 +1308,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1343,9 +1322,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1360,9 +1336,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1377,9 +1350,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1394,9 +1364,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1411,9 +1378,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1428,9 +1392,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [

View File

@@ -18,6 +18,7 @@ import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './rout
import placesRoutes from './routes/places';
import assignmentsRoutes from './routes/assignments';
import packingRoutes from './routes/packing';
import todoRoutes from './routes/todo';
import tagsRoutes from './routes/tags';
import categoriesRoutes from './routes/categories';
import adminRoutes from './routes/admin';
@@ -179,6 +180,7 @@ export function createApp(): express.Application {
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
app.use('/api/trips/:tripId/places', placesRoutes);
app.use('/api/trips/:tripId/packing', packingRoutes);
app.use('/api/trips/:tripId/todo', todoRoutes);
app.use('/api/trips/:tripId/files', filesRoutes);
app.use('/api/trips/:tripId/budget', budgetRoutes);
app.use('/api/trips/:tripId/collab', collabRoutes);

View File

@@ -523,6 +523,33 @@ function runMigrations(db: Database.Database): void {
try { db.exec("ALTER TABLE trip_photos ADD COLUMN album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL DEFAULT NULL"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_album_link ON trip_photos(album_link_id)');
},
// Migration 68: Todo items
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS todo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
checked INTEGER DEFAULT 0,
category TEXT,
sort_order INTEGER DEFAULT 0,
due_date TEXT,
description TEXT,
assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
priority INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_todo_items_trip_id ON todo_items(trip_id);
CREATE TABLE IF NOT EXISTS todo_category_assignees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category_name TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(trip_id, category_name, user_id)
);
`);
},
];
if (currentVersion < migrations.length) {

View File

@@ -82,7 +82,7 @@ function seedCategories(db: Database.Database): void {
function seedAddons(db: Database.Database): void {
try {
const defaultAddons = [
{ id: 'packing', name: 'Packing List', description: 'Pack your bags with checklists per trip', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
{ id: 'packing', name: 'Lists', description: 'Packing lists and to-do tasks for your trips', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },

127
server/src/routes/todo.ts Normal file
View File

@@ -0,0 +1,127 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { checkPermission } from '../services/permissions';
import { AuthRequest } from '../types';
import {
verifyTripAccess,
listItems,
createItem,
updateItem,
deleteItem,
getCategoryAssignees,
updateCategoryAssignees,
reorderItems,
} from '../services/todoService';
const router = express.Router({ mergeParams: true });
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const items = listItems(tripId);
res.json({ items });
});
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { name, category, due_date, description, assigned_user_id, priority } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!name) return res.status(400).json({ error: 'Item name is required' });
const item = createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
res.status(201).json({ item });
broadcast(tripId, 'todo:created', { item }, req.headers['x-socket-id'] as string);
});
router.put('/reorder', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { orderedIds } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
reorderItems(tripId, orderedIds);
res.json({ success: true });
});
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { name, checked, category, due_date, description, assigned_user_id, priority } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const updated = updateItem(tripId, id, { name, checked, category, due_date, description, assigned_user_id, priority }, Object.keys(req.body));
if (!updated) return res.status(404).json({ error: 'Item not found' });
res.json({ item: updated });
broadcast(tripId, 'todo:updated', { item: updated }, req.headers['x-socket-id'] as string);
});
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
if (!deleteItem(tripId, id)) return res.status(404).json({ error: 'Item not found' });
res.json({ success: true });
broadcast(tripId, 'todo:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string);
});
// ── Category assignees ──────────────────────────────────────────────────────
router.get('/category-assignees', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const assignees = getCategoryAssignees(tripId);
res.json({ assignees });
});
router.put('/category-assignees/:categoryName', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, categoryName } = req.params;
const { user_ids } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const cat = decodeURIComponent(categoryName);
const rows = updateCategoryAssignees(tripId, cat, user_ids);
res.json({ assignees: rows });
broadcast(tripId, 'todo:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string);
});
export default router;

View File

@@ -0,0 +1,122 @@
import { db, canAccessTrip } from '../db/database';
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
// ── Items ──────────────────────────────────────────────────────────────────
export function listItems(tripId: string | number) {
return db.prepare(
'SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(tripId);
}
export function createItem(tripId: string | number, data: {
name: string; category?: string; due_date?: string; description?: string; assigned_user_id?: number; priority?: number;
}) {
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?').get(tripId) as { max: number | null };
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO todo_items (trip_id, name, checked, category, sort_order, due_date, description, assigned_user_id, priority) VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?)'
).run(
tripId, data.name, data.category || null, sortOrder,
data.due_date || null, data.description || null, data.assigned_user_id || null, data.priority || 0
);
return db.prepare('SELECT * FROM todo_items WHERE id = ?').get(result.lastInsertRowid);
}
export function updateItem(
tripId: string | number,
id: string | number,
data: { name?: string; checked?: number; category?: string; due_date?: string | null; description?: string | null; assigned_user_id?: number | null; priority?: number | null },
bodyKeys: string[]
) {
const item = db.prepare('SELECT * FROM todo_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return null;
db.prepare(`
UPDATE todo_items SET
name = COALESCE(?, name),
checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END,
category = COALESCE(?, category),
due_date = CASE WHEN ? THEN ? ELSE due_date END,
description = CASE WHEN ? THEN ? ELSE description END,
assigned_user_id = CASE WHEN ? THEN ? ELSE assigned_user_id END,
priority = CASE WHEN ? THEN ? ELSE priority END
WHERE id = ?
`).run(
data.name || null,
data.checked !== undefined ? 1 : null,
data.checked ? 1 : 0,
data.category || null,
bodyKeys.includes('due_date') ? 1 : 0,
data.due_date ?? null,
bodyKeys.includes('description') ? 1 : 0,
data.description ?? null,
bodyKeys.includes('assigned_user_id') ? 1 : 0,
data.assigned_user_id ?? null,
bodyKeys.includes('priority') ? 1 : 0,
data.priority ?? 0,
id
);
return db.prepare('SELECT * FROM todo_items WHERE id = ?').get(id);
}
export function deleteItem(tripId: string | number, id: string | number) {
const item = db.prepare('SELECT id FROM todo_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return false;
db.prepare('DELETE FROM todo_items WHERE id = ?').run(id);
return true;
}
// ── Category Assignees ─────────────────────────────────────────────────────
export function getCategoryAssignees(tripId: string | number) {
const rows = db.prepare(`
SELECT tca.category_name, tca.user_id, u.username, u.avatar
FROM todo_category_assignees tca
JOIN users u ON tca.user_id = u.id
WHERE tca.trip_id = ?
`).all(tripId);
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
for (const row of rows as any[]) {
if (!assignees[row.category_name]) assignees[row.category_name] = [];
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
}
return assignees;
}
export function updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) {
db.prepare('DELETE FROM todo_category_assignees WHERE trip_id = ? AND category_name = ?').run(tripId, categoryName);
if (Array.isArray(userIds) && userIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)');
for (const uid of userIds) insert.run(tripId, categoryName, uid);
}
return db.prepare(`
SELECT tca.user_id, u.username, u.avatar
FROM todo_category_assignees tca
JOIN users u ON tca.user_id = u.id
WHERE tca.trip_id = ? AND tca.category_name = ?
`).all(tripId, categoryName);
}
// ── Reorder ────────────────────────────────────────────────────────────────
export function reorderItems(tripId: string | number, orderedIds: number[]) {
const update = db.prepare('UPDATE todo_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((ids: number[]) => {
ids.forEach((id, index) => {
update.run(index, id, tripId);
});
});
updateMany(orderedIds);
}