diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 81a28b6..179021c 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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) => apiClient.post(`/trips/${tripId}/todo`, data).then(r => r.data), + update: (tripId: number | string, id: number, data: Record) => 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) => apiClient.post('/tags', data).then(r => r.data), diff --git a/client/src/components/Layout/InAppNotificationBell.tsx b/client/src/components/Layout/InAppNotificationBell.tsx index fcf14cb..0b22038 100644 --- a/client/src/components/Layout/InAppNotificationBell.tsx +++ b/client/src/components/Layout/InAppNotificationBell.tsx @@ -96,7 +96,7 @@ export default function InAppNotificationBell(): React.ReactElement { {t('notifications.title')} {unreadCount > 0 && ( + style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}> {unreadCount} )} @@ -133,7 +133,7 @@ export default function InAppNotificationBell(): React.ReactElement {
{isLoading && notifications.length === 0 ? (
-
+
) : notifications.length === 0 ? (
@@ -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)'} diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index 1d92876..e4e1dc9 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -133,7 +133,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: {tripTitle && ( <> / - + {tripTitle} @@ -155,17 +155,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: )} - {/* Dark mode toggle (light ↔ dark, overrides auto) */} + {/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */} - {/* Notification bell */} - {user && } + {/* Notification bell — only in trip view on mobile, everywhere on desktop */} + {user && tripId && } + {user && !tripId && } {/* User menu */} {user && ( diff --git a/client/src/components/Notifications/InAppNotificationItem.tsx b/client/src/components/Notifications/InAppNotificationItem.tsx index a791fe7..f0ef4fa 100644 --- a/client/src/components/Notifications/InAppNotificationItem.tsx +++ b/client/src/components/Notifications/InAppNotificationItem.tsx @@ -59,10 +59,6 @@ export default function InAppNotificationItem({ notification, onClose }: Notific borderBottom: '1px solid var(--border-secondary)', }} > - {/* Unread dot */} - {!notification.is_read && ( -
- )}
{/* 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)' }} > @@ -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'), diff --git a/client/src/components/Todo/TodoListPanel.tsx b/client/src/components/Todo/TodoListPanel.tsx new file mode 100644 index 0000000..10c2d39 --- /dev/null +++ b/client/src/components/Todo/TodoListPanel.tsx @@ -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 = { + 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('all') + const [selectedId, setSelectedId] = useState(null) + const [isAddingNew, setIsAddingNew] = useState(false) + const [sortByPrio, setSortByPrio] = useState(false) + const [addingCategory, setAddingCategory] = useState(false) + const [newCategoryName, setNewCategoryName] = useState('') + const [members, setMembers] = useState([]) + const [currentUserId, setCurrentUserId] = useState(null) + + useEffect(() => { + apiClient.get(`/trips/${tripId}/members`).then(r => { + const owner = r.data?.owner + const mems = r.data?.members || [] + const all = owner ? [owner, ...mems] : mems + setMembers(all) + setCurrentUserId(r.data?.current_user_id || null) + }).catch(() => {}) + }, [tripId]) + + const categories = useMemo(() => { + const cats = new Set() + items.forEach(i => { if (i.category) cats.add(i.category) }) + return Array.from(cats).sort() + }, [items]) + + const today = new Date().toISOString().split('T')[0] + + const filtered = useMemo(() => { + let result: TodoItem[] + if (filter === 'all') result = items.filter(i => !i.checked) + else if (filter === 'done') result = items.filter(i => !!i.checked) + else if (filter === 'my') result = items.filter(i => !i.checked && i.assigned_user_id === currentUserId) + else if (filter === 'overdue') result = items.filter(i => !i.checked && i.due_date && i.due_date < today) + else result = items.filter(i => i.category === filter) + if (sortByPrio) result = [...result].sort((a, b) => { + const ap = a.priority || 99 + const bp = b.priority || 99 + return ap - bp + }) + return result + }, [items, filter, currentUserId, today, sortByPrio]) + + const selectedItem = items.find(i => i.id === selectedId) || null + const totalCount = items.length + const doneCount = items.filter(i => !!i.checked).length + const overdueCount = items.filter(i => !i.checked && i.due_date && i.due_date < today).length + const myCount = currentUserId ? items.filter(i => !i.checked && i.assigned_user_id === currentUserId).length : 0 + + const addCategory = () => { + const name = newCategoryName.trim() + if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return } + addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any) + .then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) }) + .catch(err => toast.error(err instanceof Error ? err.message : 'Error')) + } + + // Get category count (non-done items) + const catCount = (cat: string) => items.filter(i => i.category === cat && !i.checked).length + + // Sidebar filter item + const SidebarItem = ({ id, icon: Icon, label, count, color }: { id: string; icon: any; label: string; count: number; color?: string }) => ( + + ) + + // Filter title + const filterTitle = (() => { + if (filter === 'all') return t('todo.filter.all') + if (filter === 'done') return t('todo.filter.done') + if (filter === 'my') return t('todo.filter.my') + if (filter === 'overdue') return t('todo.filter.overdue') + return filter + })() + + return ( +
+ + {/* ── Left Sidebar ── */} +
+ {/* Progress Card */} + {!isMobile &&
+
+ + {totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0}% + +
+
+
0 ? `${Math.round((doneCount / totalCount) * 100)}%` : '0%', background: '#22c55e', borderRadius: 2, transition: 'width 0.3s' }} /> +
+
+ {doneCount} / {totalCount} {t('todo.completed')} +
+
} + + {/* Smart filters */} + {!isMobile &&
+ {t('todo.sidebar.tasks')} +
} + !i.checked).length} /> + + + + + {/* Sort by priority */} + + + {/* Categories */} + {!isMobile &&
+ {t('todo.sidebar.categories')} +
} + {isMobile &&
} + {categories.map(cat => ( + + ))} + + {canEdit && ( + addingCategory && !isMobile ? ( +
+ setNewCategoryName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCategoryName('') } }} + placeholder={t('todo.newCategory')} + style={{ flex: 1, fontSize: 12, padding: '4px 6px', border: '1px solid var(--border-primary)', borderRadius: 5, background: 'var(--bg-hover)', color: 'var(--text-primary)', fontFamily: 'inherit', minWidth: 0 }} /> + +
+ ) : ( + + ) + )} +
+ + {/* ── Middle: Task List ── */} +
+ {/* Header */} +
+
+

+ {filterTitle} +

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