From 47671d52e04cedf6c56d2860c5784d6e964f833e Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 31 May 2026 20:07:17 +0200 Subject: [PATCH] Decompose the remaining God Components into hooks, helpers and sub-components FE6: split the oversized page and panel components into thin layout shells plus colocated use hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted. --- .../Budget/BudgetPanel.constants.ts | 29 + .../components/Budget/BudgetPanel.helpers.ts | 73 + client/src/components/Budget/BudgetPanel.tsx | 1164 +-------- .../Budget/BudgetPanelAddItemRow.tsx | 67 + .../Budget/BudgetPanelCategoryTable.tsx | 258 ++ .../Budget/BudgetPanelInlineEditCell.tsx | 71 + .../Budget/BudgetPanelMemberChips.tsx | 179 ++ .../Budget/BudgetPanelPerPersonInline.tsx | 64 + .../components/Budget/BudgetPanelPieChart.tsx | 53 + .../Budget/BudgetPanelRingAvatar.tsx | 22 + .../components/Budget/BudgetPanelSummary.tsx | 280 ++ .../src/components/Budget/useBudgetPanel.ts | 211 ++ .../components/Collab/CollabChat.constants.ts | 10 + .../components/Collab/CollabChat.helpers.ts | 42 + client/src/components/Collab/CollabChat.tsx | 765 +----- .../src/components/Collab/CollabChat.types.ts | 17 + .../Collab/CollabChatEmojiPicker.tsx | 76 + .../Collab/CollabChatLinkPreview.tsx | 65 + .../Collab/CollabChatMessageText.tsx | 21 + .../components/Collab/CollabChatMessages.tsx | 250 ++ .../Collab/CollabChatReactionBadge.tsx | 53 + .../Collab/CollabChatReactionMenu.tsx | 47 + .../Collab/CollabChatTwemojiImg.tsx | 21 + .../Collab/CollabNotes.constants.ts | 10 + .../components/Collab/CollabNotes.helpers.ts | 16 + client/src/components/Collab/CollabNotes.tsx | 905 +------ .../components/Collab/CollabNotes.types.ts | 34 + .../Collab/CollabNotesAuthedImg.tsx | 10 + .../src/components/Collab/CollabNotesCard.tsx | 198 ++ .../CollabNotesCategorySettingsModal.tsx | 145 ++ .../Collab/CollabNotesEditableCatName.tsx | 34 + .../Collab/CollabNotesFilePreviewPortal.tsx | 73 + .../Collab/CollabNotesFormModal.tsx | 311 +++ .../Collab/CollabNotesUserAvatar.tsx | 48 + .../Collab/CollabNotesWebsiteThumbnail.tsx | 47 + client/src/components/Collab/useCollabChat.ts | 179 ++ .../components/Files/FileManager.constants.ts | 1 + .../components/Files/FileManager.helpers.ts | 39 + client/src/components/Files/FileManager.tsx | 1033 +------- .../Files/FileManagerAssignModal.tsx | 218 ++ .../components/Files/FileManagerAuthedImg.tsx | 11 + .../Files/FileManagerAvatarChip.tsx | 45 + .../components/Files/FileManagerFilesView.tsx | 79 + .../Files/FileManagerImageLightbox.tsx | 128 + .../Files/FileManagerPdfPreviewModal.tsx | 57 + .../src/components/Files/FileManagerRow.tsx | 136 + .../Files/FileManagerSourceBadge.tsx | 20 + .../components/Files/FileManagerToolbar.tsx | 83 + .../components/Files/FileManagerTrashView.tsx | 36 + client/src/components/Files/useFileManager.ts | 209 ++ .../JourneyDetailPageAddTripDialog.tsx | 98 + .../Journey/JourneyDetailPageChips.tsx | 28 + .../Journey/JourneyDetailPageDatePicker.tsx | 111 + .../Journey/JourneyDetailPageEntryCard.tsx | 200 ++ .../Journey/JourneyDetailPageEntryEditor.tsx | 500 ++++ .../JourneyDetailPageExpandableStory.tsx | 55 + .../Journey/JourneyDetailPageGalleryView.tsx | 230 ++ .../Journey/JourneyDetailPageMapView.tsx | 140 + .../Journey/JourneyDetailPagePhotoGrid.tsx | 62 + .../JourneyDetailPageProviderPicker.tsx | 418 +++ .../JourneyDetailPageScrollTrigger.tsx | 17 + .../JourneyDetailPageSettingsDialog.tsx | 300 +++ .../JourneyDetailPageVerdictSection.tsx | 96 + .../components/Packing/PackingListPanel.tsx | 1578 +---------- .../Packing/PackingListPanelBagCard.tsx | 102 + .../Packing/PackingListPanelBagModal.tsx | 81 + .../Packing/PackingListPanelBagSidebar.tsx | 73 + .../Packing/PackingListPanelCategoryGroup.tsx | 300 +++ .../Packing/PackingListPanelFilterTabs.tsx | 17 + .../Packing/PackingListPanelHeader.tsx | 219 ++ .../Packing/PackingListPanelImportModal.tsx | 70 + .../Packing/PackingListPanelItemRow.tsx | 257 ++ .../Packing/PackingListPanelList.tsx | 48 + .../Packing/PackingListPanelQuantityInput.tsx | 26 + .../Packing/packingListPanel.constants.ts | 52 + .../Packing/packingListPanel.helpers.test.ts | 85 + .../Packing/packingListPanel.helpers.ts | 55 + .../components/Packing/usePackingListPanel.ts | 313 +++ .../Planner/DayPlanSidebar.constants.ts | 39 + .../src/components/Planner/DayPlanSidebar.tsx | 630 +---- .../Planner/DayPlanSidebarFooter.tsx | 17 + .../DayPlanSidebarMobileAddPlaceButton.tsx | 97 + .../Planner/DayPlanSidebarNoteModal.tsx | 82 + .../Planner/DayPlanSidebarRouteConnector.tsx | 21 + .../DayPlanSidebarTimeConfirmModal.tsx | 63 + .../Planner/DayPlanSidebarToolbar.tsx | 203 ++ .../DayPlanSidebarTransportDetailModal.tsx | 181 ++ .../Planner/PlaceFormModal.helpers.test.ts | 41 + .../Planner/PlaceFormModal.helpers.ts | 50 + .../src/components/Planner/PlaceFormModal.tsx | 52 +- .../src/components/Planner/PlacesSidebar.tsx | 893 +------ .../Planner/PlacesSidebarHeader.tsx | 270 ++ .../components/Planner/PlacesSidebarList.tsx | 53 + .../Planner/PlacesSidebarListImportModal.tsx | 87 + .../Planner/PlacesSidebarMobileDayPicker.tsx | 88 + .../components/Planner/PlacesSidebarRow.tsx | 111 + .../Planner/PlacesSidebarSelectionBar.tsx | 58 + .../components/Planner/usePlacesSidebar.ts | 235 ++ client/src/pages/AdminPage.tsx | 1404 +--------- client/src/pages/JourneyDetailPage.tsx | 2296 +---------------- .../pages/admin/AdminNotificationsPanel.tsx | 96 + .../src/pages/admin/AdminNotificationsTab.tsx | 366 +++ client/src/pages/admin/AdminPage.constants.ts | 12 + client/src/pages/admin/AdminSettingsTab.tsx | 445 ++++ client/src/pages/admin/AdminStatCard.tsx | 18 + client/src/pages/admin/AdminUserModals.tsx | 292 +++ client/src/pages/admin/AdminUsersTab.tsx | 231 ++ .../JourneyDetailPage.constants.ts | 29 + .../JourneyDetailPage.helpers.ts | 47 + 109 files changed, 11415 insertions(+), 10566 deletions(-) create mode 100644 client/src/components/Budget/BudgetPanel.constants.ts create mode 100644 client/src/components/Budget/BudgetPanel.helpers.ts create mode 100644 client/src/components/Budget/BudgetPanelAddItemRow.tsx create mode 100644 client/src/components/Budget/BudgetPanelCategoryTable.tsx create mode 100644 client/src/components/Budget/BudgetPanelInlineEditCell.tsx create mode 100644 client/src/components/Budget/BudgetPanelMemberChips.tsx create mode 100644 client/src/components/Budget/BudgetPanelPerPersonInline.tsx create mode 100644 client/src/components/Budget/BudgetPanelPieChart.tsx create mode 100644 client/src/components/Budget/BudgetPanelRingAvatar.tsx create mode 100644 client/src/components/Budget/BudgetPanelSummary.tsx create mode 100644 client/src/components/Budget/useBudgetPanel.ts create mode 100644 client/src/components/Collab/CollabChat.constants.ts create mode 100644 client/src/components/Collab/CollabChat.helpers.ts create mode 100644 client/src/components/Collab/CollabChat.types.ts create mode 100644 client/src/components/Collab/CollabChatEmojiPicker.tsx create mode 100644 client/src/components/Collab/CollabChatLinkPreview.tsx create mode 100644 client/src/components/Collab/CollabChatMessageText.tsx create mode 100644 client/src/components/Collab/CollabChatMessages.tsx create mode 100644 client/src/components/Collab/CollabChatReactionBadge.tsx create mode 100644 client/src/components/Collab/CollabChatReactionMenu.tsx create mode 100644 client/src/components/Collab/CollabChatTwemojiImg.tsx create mode 100644 client/src/components/Collab/CollabNotes.constants.ts create mode 100644 client/src/components/Collab/CollabNotes.helpers.ts create mode 100644 client/src/components/Collab/CollabNotes.types.ts create mode 100644 client/src/components/Collab/CollabNotesAuthedImg.tsx create mode 100644 client/src/components/Collab/CollabNotesCard.tsx create mode 100644 client/src/components/Collab/CollabNotesCategorySettingsModal.tsx create mode 100644 client/src/components/Collab/CollabNotesEditableCatName.tsx create mode 100644 client/src/components/Collab/CollabNotesFilePreviewPortal.tsx create mode 100644 client/src/components/Collab/CollabNotesFormModal.tsx create mode 100644 client/src/components/Collab/CollabNotesUserAvatar.tsx create mode 100644 client/src/components/Collab/CollabNotesWebsiteThumbnail.tsx create mode 100644 client/src/components/Collab/useCollabChat.ts create mode 100644 client/src/components/Files/FileManager.constants.ts create mode 100644 client/src/components/Files/FileManager.helpers.ts create mode 100644 client/src/components/Files/FileManagerAssignModal.tsx create mode 100644 client/src/components/Files/FileManagerAuthedImg.tsx create mode 100644 client/src/components/Files/FileManagerAvatarChip.tsx create mode 100644 client/src/components/Files/FileManagerFilesView.tsx create mode 100644 client/src/components/Files/FileManagerImageLightbox.tsx create mode 100644 client/src/components/Files/FileManagerPdfPreviewModal.tsx create mode 100644 client/src/components/Files/FileManagerRow.tsx create mode 100644 client/src/components/Files/FileManagerSourceBadge.tsx create mode 100644 client/src/components/Files/FileManagerToolbar.tsx create mode 100644 client/src/components/Files/FileManagerTrashView.tsx create mode 100644 client/src/components/Files/useFileManager.ts create mode 100644 client/src/components/Journey/JourneyDetailPageAddTripDialog.tsx create mode 100644 client/src/components/Journey/JourneyDetailPageChips.tsx create mode 100644 client/src/components/Journey/JourneyDetailPageDatePicker.tsx create mode 100644 client/src/components/Journey/JourneyDetailPageEntryCard.tsx create mode 100644 client/src/components/Journey/JourneyDetailPageEntryEditor.tsx create mode 100644 client/src/components/Journey/JourneyDetailPageExpandableStory.tsx create mode 100644 client/src/components/Journey/JourneyDetailPageGalleryView.tsx create mode 100644 client/src/components/Journey/JourneyDetailPageMapView.tsx create mode 100644 client/src/components/Journey/JourneyDetailPagePhotoGrid.tsx create mode 100644 client/src/components/Journey/JourneyDetailPageProviderPicker.tsx create mode 100644 client/src/components/Journey/JourneyDetailPageScrollTrigger.tsx create mode 100644 client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx create mode 100644 client/src/components/Journey/JourneyDetailPageVerdictSection.tsx create mode 100644 client/src/components/Packing/PackingListPanelBagCard.tsx create mode 100644 client/src/components/Packing/PackingListPanelBagModal.tsx create mode 100644 client/src/components/Packing/PackingListPanelBagSidebar.tsx create mode 100644 client/src/components/Packing/PackingListPanelCategoryGroup.tsx create mode 100644 client/src/components/Packing/PackingListPanelFilterTabs.tsx create mode 100644 client/src/components/Packing/PackingListPanelHeader.tsx create mode 100644 client/src/components/Packing/PackingListPanelImportModal.tsx create mode 100644 client/src/components/Packing/PackingListPanelItemRow.tsx create mode 100644 client/src/components/Packing/PackingListPanelList.tsx create mode 100644 client/src/components/Packing/PackingListPanelQuantityInput.tsx create mode 100644 client/src/components/Packing/packingListPanel.constants.ts create mode 100644 client/src/components/Packing/packingListPanel.helpers.test.ts create mode 100644 client/src/components/Packing/packingListPanel.helpers.ts create mode 100644 client/src/components/Packing/usePackingListPanel.ts create mode 100644 client/src/components/Planner/DayPlanSidebar.constants.ts create mode 100644 client/src/components/Planner/DayPlanSidebarFooter.tsx create mode 100644 client/src/components/Planner/DayPlanSidebarMobileAddPlaceButton.tsx create mode 100644 client/src/components/Planner/DayPlanSidebarNoteModal.tsx create mode 100644 client/src/components/Planner/DayPlanSidebarRouteConnector.tsx create mode 100644 client/src/components/Planner/DayPlanSidebarTimeConfirmModal.tsx create mode 100644 client/src/components/Planner/DayPlanSidebarToolbar.tsx create mode 100644 client/src/components/Planner/DayPlanSidebarTransportDetailModal.tsx create mode 100644 client/src/components/Planner/PlaceFormModal.helpers.test.ts create mode 100644 client/src/components/Planner/PlaceFormModal.helpers.ts create mode 100644 client/src/components/Planner/PlacesSidebarHeader.tsx create mode 100644 client/src/components/Planner/PlacesSidebarList.tsx create mode 100644 client/src/components/Planner/PlacesSidebarListImportModal.tsx create mode 100644 client/src/components/Planner/PlacesSidebarMobileDayPicker.tsx create mode 100644 client/src/components/Planner/PlacesSidebarRow.tsx create mode 100644 client/src/components/Planner/PlacesSidebarSelectionBar.tsx create mode 100644 client/src/components/Planner/usePlacesSidebar.ts create mode 100644 client/src/pages/admin/AdminNotificationsPanel.tsx create mode 100644 client/src/pages/admin/AdminNotificationsTab.tsx create mode 100644 client/src/pages/admin/AdminPage.constants.ts create mode 100644 client/src/pages/admin/AdminSettingsTab.tsx create mode 100644 client/src/pages/admin/AdminStatCard.tsx create mode 100644 client/src/pages/admin/AdminUserModals.tsx create mode 100644 client/src/pages/admin/AdminUsersTab.tsx create mode 100644 client/src/pages/journeyDetail/JourneyDetailPage.constants.ts create mode 100644 client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts diff --git a/client/src/components/Budget/BudgetPanel.constants.ts b/client/src/components/Budget/BudgetPanel.constants.ts new file mode 100644 index 00000000..d5f2bd8a --- /dev/null +++ b/client/src/components/Budget/BudgetPanel.constants.ts @@ -0,0 +1,29 @@ +export const CURRENCIES = [ + 'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', + 'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR', + 'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS', + 'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT', + 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS', +] + +export const SYMBOLS: Record = { + EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', + SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$', + NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM', + PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$', + ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD', + HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽', + UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$', + PEN: 'S/.', ARS: 'AR$', +} + +export const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7'] + +export const SPLIT_COLORS = [ + { solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }, + { solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' }, + { solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' }, + { solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' }, + { solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' }, + { solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' }, +] diff --git a/client/src/components/Budget/BudgetPanel.helpers.ts b/client/src/components/Budget/BudgetPanel.helpers.ts new file mode 100644 index 00000000..855c3e3b --- /dev/null +++ b/client/src/components/Budget/BudgetPanel.helpers.ts @@ -0,0 +1,73 @@ +import { currencyDecimals } from '../../utils/formatters' +import { SYMBOLS, SPLIT_COLORS } from './BudgetPanel.constants' + +export function widgetTheme(dark: boolean) { + if (dark) return { + bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)', + border: 'rgba(255,255,255,0.07)', + text: '#ffffff', + sub: 'rgba(255,255,255,0.6)', + faint: 'rgba(255,255,255,0.4)', + track: 'rgba(255,255,255,0.04)', + divider: 'rgba(255,255,255,0.07)', + iconBg: 'rgba(255,255,255,0.08)', + iconBorder: 'rgba(255,255,255,0.12)', + iconColor: 'rgba(255,255,255,0.9)', + centerBg: '#17171d', + flowBg: 'rgba(255,255,255,0.05)', + flowBorder: 'rgba(255,255,255,0.07)', + flowHoverBg: 'rgba(255,255,255,0.08)', + flowHoverBorder: 'rgba(255,255,255,0.12)', + rowHover: 'rgba(255,255,255,0.03)', + shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)', + donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))', + } + return { + bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)', + border: 'rgba(15,23,42,0.08)', + text: '#111827', + sub: 'rgba(17,24,39,0.6)', + faint: 'rgba(17,24,39,0.4)', + track: 'rgba(15,23,42,0.05)', + divider: 'rgba(15,23,42,0.08)', + iconBg: 'rgba(15,23,42,0.05)', + iconBorder: 'rgba(15,23,42,0.1)', + iconColor: 'rgba(17,24,39,0.75)', + centerBg: '#ffffff', + flowBg: 'rgba(15,23,42,0.03)', + flowBorder: 'rgba(15,23,42,0.08)', + flowHoverBg: 'rgba(15,23,42,0.06)', + flowHoverBorder: 'rgba(15,23,42,0.14)', + rowHover: 'rgba(15,23,42,0.04)', + shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)', + donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))', + } +} + +export function hexLighten(hex: string, amount: number): string { + const m = hex.replace('#', '').match(/.{2}/g) + if (!m || m.length !== 3) return hex + const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount)) + const [r, g, b] = m.map(x => parseInt(x, 16)) + return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}` +} + +export const fmtNum = (v: number | null | undefined, locale: string, cur: string) => { + if (v == null || isNaN(v)) return '-' + const d = currencyDecimals(cur) + return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur) +} + +type NumOrNull = number | null | undefined + +export const calcPP = (p: NumOrNull, n: NumOrNull) => (n! > 0 ? (p as number) / (n as number) : null) +export const calcPD = (p: NumOrNull, d: NumOrNull) => (d! > 0 ? (p as number) / (d as number) : null) +export const calcPPD = (p: NumOrNull, n: NumOrNull, d: NumOrNull) => (n! > 0 && d! > 0 ? (p as number) / ((n as number) * (d as number)) : null) + +export function splitColorFor(userId: number, order: number) { + return SPLIT_COLORS[order % SPLIT_COLORS.length] +} + +export function colorForUserId(userId: number) { + return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length] +} diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index aa13e82c..57bc843d 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -1,557 +1,12 @@ -import ReactDOM from 'react-dom' -import { useState, useEffect, useRef, useMemo, useCallback } from 'react' -import DOM from 'react-dom' -import { useTripStore } from '../../store/tripStore' -import { useCanDo } from '../../store/permissionsStore' -import { useToast } from '../shared/Toast' -import { useTranslation } from '../../i18n' -import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react' - -function useIsDark(): boolean { - const [dark, setDark] = useState(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')) - useEffect(() => { - if (typeof document === 'undefined') return - const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark'))) - mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) - return () => mo.disconnect() - }, []) - return dark -} - -function widgetTheme(dark: boolean) { - if (dark) return { - bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)', - border: 'rgba(255,255,255,0.07)', - text: '#ffffff', - sub: 'rgba(255,255,255,0.6)', - faint: 'rgba(255,255,255,0.4)', - track: 'rgba(255,255,255,0.04)', - divider: 'rgba(255,255,255,0.07)', - iconBg: 'rgba(255,255,255,0.08)', - iconBorder: 'rgba(255,255,255,0.12)', - iconColor: 'rgba(255,255,255,0.9)', - centerBg: '#17171d', - flowBg: 'rgba(255,255,255,0.05)', - flowBorder: 'rgba(255,255,255,0.07)', - flowHoverBg: 'rgba(255,255,255,0.08)', - flowHoverBorder: 'rgba(255,255,255,0.12)', - rowHover: 'rgba(255,255,255,0.03)', - shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)', - donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))', - } - return { - bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)', - border: 'rgba(15,23,42,0.08)', - text: '#111827', - sub: 'rgba(17,24,39,0.6)', - faint: 'rgba(17,24,39,0.4)', - track: 'rgba(15,23,42,0.05)', - divider: 'rgba(15,23,42,0.08)', - iconBg: 'rgba(15,23,42,0.05)', - iconBorder: 'rgba(15,23,42,0.1)', - iconColor: 'rgba(17,24,39,0.75)', - centerBg: '#ffffff', - flowBg: 'rgba(15,23,42,0.03)', - flowBorder: 'rgba(15,23,42,0.08)', - flowHoverBg: 'rgba(15,23,42,0.06)', - flowHoverBorder: 'rgba(15,23,42,0.14)', - rowHover: 'rgba(15,23,42,0.04)', - shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)', - donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))', - } -} - -function hexLighten(hex: string, amount: number): string { - const m = hex.replace('#', '').match(/.{2}/g) - if (!m || m.length !== 3) return hex - const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount)) - const [r, g, b] = m.map(x => parseInt(x, 16)) - return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}` -} +import { Plus, Calculator, Download } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' -import { budgetApi } from '../../api/client' -import { CustomDatePicker } from '../shared/CustomDateTimePicker' -import type { BudgetItem, BudgetItemMember } from '../../types' -import { currencyDecimals } from '../../utils/formatters' +import { CURRENCIES, SYMBOLS } from './BudgetPanel.constants' +import { useBudgetPanel } from './useBudgetPanel' +import type { TripMember } from './BudgetPanelMemberChips' +import BudgetCategoryTable from './BudgetPanelCategoryTable' +import BudgetSummary from './BudgetPanelSummary' -interface TripMember { - id: number - username: string - avatar_url?: string | null -} - -interface PieSegment { - label: string - value: number - color: string -} - -interface PerPersonSummaryEntry { - user_id: number - username: string - avatar_url: string | null - total_assigned: number -} - -// ── Helpers ────────────────────────────────────────────────────────────────── -const CURRENCIES = [ - 'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', - 'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR', - 'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS', - 'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT', - 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS', -] -const SYMBOLS = { - EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', - SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$', - NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM', - PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$', - ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD', - HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽', - UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$', - PEN: 'S/.', ARS: 'AR$', -} -const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7'] - -const fmtNum = (v, locale, cur) => { - if (v == null || isNaN(v)) return '-' - const d = currencyDecimals(cur) - return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur) -} - -const calcPP = (p, n) => (n > 0 ? p / n : null) -const calcPD = (p, d) => (d > 0 ? p / d : null) -const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null) - -// ── Inline Edit Cell ───────────────────────────────────────────────────────── -function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) { - const [editing, setEditing] = useState(false) - const [editValue, setEditValue] = useState(value ?? '') - const inputRef = useRef(null) - - useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing]) - - const save = () => { - setEditing(false) - let v = editValue - if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p } - if (v !== value) onSave(v) - } - - const handlePaste = (e) => { - if (type !== 'number') return - e.preventDefault() - let text = e.clipboardData.getData('text').trim() - // Strip everything except digits, dots, commas, minus - text = text.replace(/[^\d.,-]/g, '') - // Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal - const lastComma = text.lastIndexOf(',') - const lastDot = text.lastIndexOf('.') - const decimalPos = Math.max(lastComma, lastDot) - if (decimalPos > -1) { - const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '') - const decPart = text.substring(decimalPos + 1) - text = intPart + '.' + decPart - } else { - text = text.replace(/[.,]/g, '') - } - setEditValue(text) - } - - if (editing) { - return setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste} - onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }} - style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }} - placeholder={placeholder} /> - } - - const display = type === 'number' && value != null - ? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) - : (value || '') - - return ( -
{ if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip} - style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center', - justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s', - color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }} - onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }} - onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}> - {display || placeholder || '-'} -
- ) -} - -// ── Add Item Row ───────────────────────────────────────────────────────────── -interface AddItemRowProps { - onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void - t: (key: string) => string -} - -function AddItemRow({ onAdd, t }: AddItemRowProps) { - const [name, setName] = useState('') - const [price, setPrice] = useState('') - const [persons, setPersons] = useState('') - const [days, setDays] = useState('') - const [note, setNote] = useState('') - const [expenseDate, setExpenseDate] = useState('') - const nameRef = useRef(null) - - const handleAdd = () => { - if (!name.trim()) return - onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null }) - setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('') - setTimeout(() => nameRef.current?.focus(), 50) - } - - const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' } - - return ( - - - setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder={t('budget.newEntry')} style={inp} /> - - - setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }} - placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} /> - - - setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> - - - setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> - - - - - - - - -
- -
- - - setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} /> - - - - - - ) -} - -// ── Chip with custom tooltip ───────────────────────────────────────────────── -interface ChipWithTooltipProps { - label: string - avatarUrl: string | null - size?: number - paid?: boolean - onClick?: () => void -} - -function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) { - const [hover, setHover] = useState(false) - const [pos, setPos] = useState({ top: 0, left: 0 }) - const ref = useRef(null) - - const onEnter = () => { - if (ref.current) { - const rect = ref.current.getBoundingClientRect() - setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 }) - } - setHover(true) - } - - const borderColor = paid ? '#22c55e' : 'var(--border-primary)' - const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)' - - return ( - <> -
setHover(false)} - onClick={onClick} - style={{ - width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, - background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', - fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', - overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', - transition: 'border-color 0.15s, background 0.15s', - }}> - {avatarUrl - ? - : label?.[0]?.toUpperCase() - } -
- {hover && ReactDOM.createPortal( -
- {label} - {paid && ( - Paid - )} -
, - document.body - )} - - ) -} - -// ── Budget Member Chips (for Persons column) ──────────────────────────────── -interface BudgetMemberChipsProps { - members?: BudgetItemMember[] - tripMembers?: TripMember[] - onSetMembers: (memberIds: number[]) => void - onTogglePaid?: (userId: number, paid: boolean) => void - compact?: boolean - readOnly?: boolean -} - -function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) { - const chipSize = compact ? 20 : 30 - const btnSize = compact ? 18 : 28 - const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14) - const [showDropdown, setShowDropdown] = useState(false) - const [dropPos, setDropPos] = useState({ top: 0, left: 0 }) - const btnRef = useRef(null) - const dropRef = useRef(null) - - const openDropdown = useCallback(() => { - if (btnRef.current) { - const rect = btnRef.current.getBoundingClientRect() - setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 }) - } - setShowDropdown(v => !v) - }, []) - - useEffect(() => { - if (!showDropdown) return - const close = (e) => { - if (dropRef.current && dropRef.current.contains(e.target)) return - if (btnRef.current && btnRef.current.contains(e.target)) return - setShowDropdown(false) - } - document.addEventListener('mousedown', close) - return () => document.removeEventListener('mousedown', close) - }, [showDropdown]) - - const memberIds = members.map(m => m.user_id) - - const toggleMember = (userId) => { - const newIds = memberIds.includes(userId) - ? memberIds.filter(id => id !== userId) - : [...memberIds, userId] - onSetMembers(newIds) - } - - return ( -
- {members.map(m => ( - onTogglePaid(m.user_id, !m.paid) : undefined} - /> - ))} - {!readOnly && ( - - )} - {showDropdown && ReactDOM.createPortal( -
- {tripMembers.map(tm => { - const isActive = memberIds.includes(tm.id) - return ( - - ) - })} -
, - document.body - )} -
- ) -} - -// ── Per-Person Inline (inside total card) ──────────────────────────────────── -interface PerPersonInlineProps { - tripId: number - budgetItems: BudgetItem[] - currency: string - locale: string -} - -const SPLIT_COLORS = [ - { solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }, - { solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' }, - { solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' }, - { solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' }, - { solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' }, - { solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' }, -] - -export function splitColorFor(userId: number, order: number) { - return SPLIT_COLORS[order % SPLIT_COLORS.length] -} - -function colorForUserId(userId: number) { - return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length] -} - -function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) { - const color = colorForUserId(userId) - return ( -
-
- {avatarUrl ? : username?.[0]?.toUpperCase()} -
-
- ) -} - -function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType }) { - const [data, setData] = useState(null) - const fmt = (v: number) => fmtNum(v, locale, currency) - - useEffect(() => { - budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {}) - }, [tripId, budgetItems]) - - if (!data || data.length === 0) return null - - const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) })) - - return ( - <> - {grandTotal > 0 && ( -
- {people.map(p => ( -
- ))} -
- )} - -
- {people.map(p => { - const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0 - return ( -
- -
-
{p.username}
-
{percent}%
-
-
{fmt(p.total_assigned)}
-
- ) - })} -
- - ) -} - -// ── Pie Chart (pure CSS conic-gradient) ────────────────────────────────────── -interface PieChartProps { - segments: PieSegment[] - size?: number - totalLabel: string -} - -function PieChart({ segments, size = 200, totalLabel }: PieChartProps) { - if (!segments.length) return null - - const total = segments.reduce((s, x) => s + x.value, 0) - if (total === 0) return null - - let cumDeg = 0 - const stops = segments.map(seg => { - const start = cumDeg - const deg = (seg.value / total) * 360 - cumDeg += deg - return `${seg.color} ${start}deg ${start + deg}deg` - }).join(', ') - - return ( -
-
-
- - {totalLabel} -
-
- ) -} +export { splitColorFor } from './BudgetPanel.helpers' // ── Main Component ─────────────────────────────────────────────────────────── interface BudgetPanelProps { @@ -560,131 +15,21 @@ interface BudgetPanelProps { } export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) { - const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore() - const can = useCanDo() - const toast = useToast() - const { t, locale } = useTranslation() - const isDark = useIsDark() - const theme = useMemo(() => widgetTheme(isDark), [isDark]) - const [newCategoryName, setNewCategoryName] = useState('') - const [editingCat, setEditingCat] = useState(null) // { name, value } - const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null) - const [settlementOpen, setSettlementOpen] = useState(false) - const currency = trip?.currency || 'EUR' - const canEdit = can('budget_edit', trip) - - const fmt = (v, cur) => fmtNum(v, locale, cur) - const hasMultipleMembers = tripMembers.length > 1 - - // Drag state for categories - const [dragCat, setDragCat] = useState(null) - const [dragOverCat, setDragOverCat] = useState(null) - // Drag state for items within a category - const [dragItem, setDragItem] = useState(null) - const [dragOverItem, setDragOverItem] = useState(null) - const [dragItemCat, setDragItemCat] = useState(null) - - // Load settlement data whenever budget items change - useEffect(() => { - if (!hasMultipleMembers) return - budgetApi.settlement(tripId).then(setSettlement).catch(() => {}) - }, [tripId, budgetItems, hasMultipleMembers]) - - const setCurrency = (cur) => { - if (tripId) updateTrip(tripId, { currency: cur }) - } - - useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId]) - - const grouped = useMemo(() => { - const map = new Map() - for (const item of (budgetItems || [])) { - const cat = item.category || 'Other' - if (!map.has(cat)) map.set(cat, []) - map.get(cat)!.push(item) - } - return map - }, [budgetItems]) - - const categoryNames = Array.from(grouped.keys()) - - // Stable color mapping: assign index-based colors once, never reassign on reorder - const colorMapRef = useRef(new Map()) - const categoryColor = useCallback((cat: string) => { - const map = colorMapRef.current - if (!map.has(cat)) { - map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length]) - } - return map.get(cat)! - }, []) - const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0) - - const pieSegments = useMemo(() => - categoryNames.map((cat, i) => ({ - name: cat, - value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0), - color: categoryColor(cat), - })).filter(s => s.value > 0) - , [grouped, categoryNames]) - - const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } } - const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch { toast.error(t('common.error')) } } - const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } } - const handleDeleteCategory = async (cat) => { - const items = grouped.get(cat) || [] - try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) } - catch { toast.error(t('common.error')) } - } - const handleRenameCategory = async (oldName, newName) => { - if (!newName.trim() || newName.trim() === oldName) return - const items = grouped.get(oldName) || [] - try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) } - catch { toast.error(t('common.error')) } - } - const handleAddCategory = () => { - if (!newCategoryName.trim()) return - Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })) - .catch(() => toast.error(t('common.error'))) - setNewCategoryName('') - } - - const handleExportCsv = () => { - const sep = ';' - const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s } - const d = currencyDecimals(currency) - const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : '' - - const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } - const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note'] - const rows = [header.join(sep)] - - for (const cat of categoryNames) { - for (const item of (grouped.get(cat) || [])) { - const pp = calcPP(item.total_price, item.persons) - const pd = calcPD(item.total_price, item.days) - const ppd = calcPPD(item.total_price, item.persons, item.days) - rows.push([ - esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')), - fmtPrice(item.total_price), item.persons ?? '', item.days ?? '', - fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd), - esc(item.note || ''), - ].join(sep)) - } - } - - const bom = '\uFEFF' - const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim() - a.download = `budget-${safeName}.csv` - a.click() - URL.revokeObjectURL(url) - } - - const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' } - const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' } + const { + budgetItems, + setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories, + t, locale, isDark, theme, + newCategoryName, setNewCategoryName, + editingCat, setEditingCat, + settlement, settlementOpen, setSettlementOpen, + currency, canEdit, fmt, hasMultipleMembers, + dragCat, setDragCat, dragOverCat, setDragOverCat, + dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat, + setCurrency, + grouped, categoryNames, categoryColor, grandTotal, pieSegments, + handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv, + th, td, + } = useBudgetPanel(tripId, tripMembers) // ── Empty State ────────────────────────────────────────────────────────── if (!budgetItems || budgetItems.length === 0) { @@ -712,7 +57,6 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro } // ── Main Layout ────────────────────────────────────────────────────────── - const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0) return (
@@ -800,467 +144,3 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
) } - -function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat, - dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem, - dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems, - handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem, - tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: any) { - const items = grouped.get(cat) || [] - const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) - const color = categoryColor(cat) - return ( -
{ - if (!dragCat || dragCat === cat || dragItem) return - e.preventDefault(); e.dataTransfer.dropEffect = 'move' - setDragOverCat(cat) - }} - onDragLeave={e => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null) - }} - onDrop={e => { - e.preventDefault() - if (dragCat && dragCat !== cat) { - const newOrder = [...categoryNames] - const fromIdx = newOrder.indexOf(dragCat) - const toIdx = newOrder.indexOf(cat) - newOrder.splice(fromIdx, 1) - newOrder.splice(toIdx, 0, dragCat) - reorderBudgetCategories(tripId, newOrder) - } - setDragCat(null); setDragOverCat(null) - }} - > - {dragOverCat === cat &&
} -
-
- {canEdit && ( -
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }} - onDragEnd={() => { setDragCat(null); setDragOverCat(null) }} - style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}> - -
- )} -
- {canEdit && editingCat?.name === cat ? ( - setEditingCat({ ...editingCat, value: e.target.value })} - onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }} - onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }} - style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }} - /> - ) : ( - <> - {cat} - {canEdit && ( - - )} - - )} -
-
- {fmt(subtotal, currency)} - {canEdit && ( - - )} -
-
- -
{ if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}> - - - - - - - - - - - - - - - - - {items.map(item => { - const pp = calcPP(item.total_price, item.persons) - const pd = calcPD(item.total_price, item.days) - const ppd = calcPPD(item.total_price, item.persons, item.days) - const hasMembers = item.members?.length > 0 - return ( - { - if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return } - if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) } - }} - onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }} - onDrop={e => { - if (dragItem && dragItemCat === cat && dragItem !== item.id) { - e.preventDefault(); e.stopPropagation() - const ids = items.map(i => i.id) - const fromIdx = ids.indexOf(dragItem) - const toIdx = ids.indexOf(item.id) - ids.splice(fromIdx, 1) - ids.splice(toIdx, 0, dragItem) - reorderBudgetItems(tripId, ids) - setDragItem(null); setDragOverItem(null); setDragItemCat(null) - } - }} - onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} - onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> - - - - - - - - - - - - ) - })} - {canEdit && handleAddItem(cat, data)} t={t} />} - -
{t('budget.table.name')}{t('budget.table.total')}{t('budget.table.persons')}{t('budget.table.days')}{t('budget.table.perPerson')}{t('budget.table.perDay')}{t('budget.table.perPersonDay')}{t('budget.table.date')}{t('budget.table.note')}
-
- {canEdit && ( -
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }} - onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }} - style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}> - -
- )} -
- handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} /> - {hasMultipleMembers && ( -
- setBudgetItemMembers(tripId, item.id, userIds)} - onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} - compact={false} - readOnly={!canEdit} - /> -
- )} -
-
-
- handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - - {hasMultipleMembers ? ( - setBudgetItemMembers(tripId, item.id, userIds)} - onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} - readOnly={!canEdit} - /> - ) : ( - handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - )} - - handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - {pp != null ? fmt(pp, currency) : '-'}{pd != null ? fmt(pd, currency) : '-'}{ppd != null ? fmt(ppd, currency) : '-'} - {canEdit ? ( -
- handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless /> -
- ) : ( - {item.expense_date || '—'} - )} -
handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> - {canEdit && ( - - )} -
-
-
- ) -} - -function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems, - settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: any) { - return ( -
- -
-
-
- -
-
-
{t('budget.totalBudget')}
-
-
- - {(() => { - const decimals = currencyDecimals(currency) - const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) - const sep = (0.1).toLocaleString(locale).replace(/\d/g, '') - const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, ''] - return ( -
- {integerPart} - {decimalPart && {sep}{decimalPart}} - {SYMBOLS[currency] || currency} -
- ) - })()} -
- {currency} -
- - {hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && ( - - )} - - {/* Settlement dropdown inside the total card */} - {hasMultipleMembers && settlement && settlement.flows.length > 0 && ( -
- - - {settlementOpen && ( -
- {settlement.flows.map((flow, i) => ( -
{ e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }} - onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }} - > - -
- - {fmt(flow.amount, currency)} - -
-
-
-
- -
- ))} - - {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && ( -
-
- {t('budget.netBalances')} -
-
- {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => { - const positive = b.balance > 0 - const Trend = positive ? TrendingUp : TrendingDown - return ( -
- - - {b.username} - - - - {positive ? '+' : ''}{fmt(b.balance, currency)} - -
- ) - })} -
-
- )} -
- )} -
- )} -
- - {pieSegments.length > 0 && (() => { - const decimals = currencyDecimals(currency) - const total = pieSegments.reduce((s, x) => s + x.value, 0) - const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) - const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '') - const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, ''] - const R = 80 - const CIRC = 2 * Math.PI * R - let dashOffset = 0 - return ( -
-
-
- -
-
-
{t('budget.byCategory')}
-
-
- -
- - - {pieSegments.map((seg, i) => { - const c2 = hexLighten(seg.color, 0.2) - return ( - - - - - ) - })} - - - {pieSegments.map((seg, i) => { - const segLen = total > 0 ? (seg.value / total) * CIRC : 0 - const circle = ( - - ) - dashOffset += segLen - return circle - })} - -
-
{t('budget.total')}
-
- {totalInt} - {totalDec && {decimalSep}{totalDec}} -
-
{currency}
-
-
- -
- {pieSegments.map((seg, i) => { - const pct = total > 0 ? (seg.value / total) * 100 : 0 - const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%' - const c2 = hexLighten(seg.color, 0.2) - const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color - return ( -
e.currentTarget.style.background = theme.rowHover} - onMouseLeave={e => e.currentTarget.style.background = 'transparent'} - > -
-
-
{seg.name}
-
{fmt(seg.value, currency)}
-
- {pctLabel} -
- ) - })} -
-
- ) - })()} - -
- ) -} diff --git a/client/src/components/Budget/BudgetPanelAddItemRow.tsx b/client/src/components/Budget/BudgetPanelAddItemRow.tsx new file mode 100644 index 00000000..544a6650 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelAddItemRow.tsx @@ -0,0 +1,67 @@ +import { useState, useRef } from 'react' +import { Plus } from 'lucide-react' +import { CustomDatePicker } from '../shared/CustomDateTimePicker' + +interface AddItemRowProps { + onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void + t: (key: string) => string +} + +export default function AddItemRow({ onAdd, t }: AddItemRowProps) { + const [name, setName] = useState('') + const [price, setPrice] = useState('') + const [persons, setPersons] = useState('') + const [days, setDays] = useState('') + const [note, setNote] = useState('') + const [expenseDate, setExpenseDate] = useState('') + const nameRef = useRef(null) + + const handleAdd = () => { + if (!name.trim()) return + onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null }) + setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('') + setTimeout(() => nameRef.current?.focus(), 50) + } + + const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' } + + return ( + + + setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} + placeholder={t('budget.newEntry')} style={inp} /> + + + setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} + onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }} + placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} /> + + + setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} + placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> + + + setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} + placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> + + - + - + - + +
+ +
+ + + setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} /> + + + + + + ) +} diff --git a/client/src/components/Budget/BudgetPanelCategoryTable.tsx b/client/src/components/Budget/BudgetPanelCategoryTable.tsx new file mode 100644 index 00000000..5eefec43 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelCategoryTable.tsx @@ -0,0 +1,258 @@ +import type { CSSProperties, Dispatch, SetStateAction } from 'react' +import { Trash2, Pencil, GripVertical } from 'lucide-react' +import type { BudgetItem } from '../../types' +import { currencyDecimals } from '../../utils/formatters' +import { CustomDatePicker } from '../shared/CustomDateTimePicker' +import { calcPP, calcPD, calcPPD } from './BudgetPanel.helpers' +import InlineEditCell from './BudgetPanelInlineEditCell' +import AddItemRow from './BudgetPanelAddItemRow' +import BudgetMemberChips, { type TripMember } from './BudgetPanelMemberChips' +import type { EditingCat, AddItemData } from './useBudgetPanel' + +interface BudgetCategoryTableProps { + cat: string + grouped: Map + categoryColor: (cat: string) => string + canEdit: boolean + editingCat: EditingCat | null + setEditingCat: Dispatch> + dragCat: string | null + setDragCat: Dispatch> + dragOverCat: string | null + setDragOverCat: Dispatch> + dragItem: number | null + setDragItem: Dispatch> + dragOverItem: number | null + setDragOverItem: Dispatch> + dragItemCat: string | null + setDragItemCat: Dispatch> + categoryNames: string[] + reorderBudgetCategories: (tripId: number | string, orderedCategories: string[]) => Promise + reorderBudgetItems: (tripId: number | string, orderedIds: number[]) => Promise + handleRenameCategory: (oldName: string, newName: string) => Promise + handleDeleteCategory: (cat: string) => Promise + handleDeleteItem: (id: number) => Promise + handleUpdateField: (id: number, field: string, value: unknown) => Promise + handleAddItem: (category: string, data: AddItemData) => Promise + tripId: number + currency: string + locale: string + t: (key: string) => string + fmt: (v: number | null | undefined, cur: string) => string + hasMultipleMembers: boolean + tripMembers: TripMember[] + setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: unknown; item: unknown }> + toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise + th: CSSProperties + td: CSSProperties +} + +export default function BudgetCategoryTable({ cat, grouped, categoryColor, canEdit, editingCat, setEditingCat, + dragCat, setDragCat, dragOverCat, setDragOverCat, dragItem, setDragItem, dragOverItem, setDragOverItem, + dragItemCat, setDragItemCat, categoryNames, reorderBudgetCategories, reorderBudgetItems, + handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleUpdateField, handleAddItem, + tripId, currency, locale, t, fmt, hasMultipleMembers, tripMembers, setBudgetItemMembers, toggleBudgetMemberPaid, th, td }: BudgetCategoryTableProps) { + const items = grouped.get(cat) || [] + const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0) + const color = categoryColor(cat) + return ( +
{ + if (!dragCat || dragCat === cat || dragItem) return + e.preventDefault(); e.dataTransfer.dropEffect = 'move' + setDragOverCat(cat) + }} + onDragLeave={e => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null) + }} + onDrop={e => { + e.preventDefault() + if (dragCat && dragCat !== cat) { + const newOrder = [...categoryNames] + const fromIdx = newOrder.indexOf(dragCat) + const toIdx = newOrder.indexOf(cat) + newOrder.splice(fromIdx, 1) + newOrder.splice(toIdx, 0, dragCat) + reorderBudgetCategories(tripId, newOrder) + } + setDragCat(null); setDragOverCat(null) + }} + > + {dragOverCat === cat &&
} +
+
+ {canEdit && ( +
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }} + onDragEnd={() => { setDragCat(null); setDragOverCat(null) }} + style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}> + +
+ )} +
+ {canEdit && editingCat?.name === cat ? ( + setEditingCat({ ...editingCat, value: e.target.value })} + onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }} + onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }} + style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }} + /> + ) : ( + <> + {cat} + {canEdit && ( + + )} + + )} +
+
+ {fmt(subtotal, currency)} + {canEdit && ( + + )} +
+
+ +
{ if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}> + + + + + + + + + + + + + + + + + {items.map(item => { + const pp = calcPP(item.total_price, item.persons) + const pd = calcPD(item.total_price, item.days) + const ppd = calcPPD(item.total_price, item.persons, item.days) + const hasMembers = (item.members?.length ?? 0) > 0 + return ( + { + if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return } + if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) } + }} + onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }} + onDrop={e => { + if (dragItem && dragItemCat === cat && dragItem !== item.id) { + e.preventDefault(); e.stopPropagation() + const ids = items.map(i => i.id) + const fromIdx = ids.indexOf(dragItem) + const toIdx = ids.indexOf(item.id) + ids.splice(fromIdx, 1) + ids.splice(toIdx, 0, dragItem) + reorderBudgetItems(tripId, ids) + setDragItem(null); setDragOverItem(null); setDragItemCat(null) + } + }} + onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> + + + + + + + + + + + + ) + })} + {canEdit && handleAddItem(cat, data)} t={t} />} + +
{t('budget.table.name')}{t('budget.table.total')}{t('budget.table.persons')}{t('budget.table.days')}{t('budget.table.perPerson')}{t('budget.table.perDay')}{t('budget.table.perPersonDay')}{t('budget.table.date')}{t('budget.table.note')}
+
+ {canEdit && ( +
{ e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }} + onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }} + style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}> + +
+ )} +
+ handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} /> + {hasMultipleMembers && ( +
+ setBudgetItemMembers(tripId, item.id, userIds)} + onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} + compact={false} + readOnly={!canEdit} + /> +
+ )} +
+
+
+ handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + + {hasMultipleMembers ? ( + setBudgetItemMembers(tripId, item.id, userIds)} + onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} + readOnly={!canEdit} + /> + ) : ( + handleUpdateField(item.id, 'persons', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + )} + + handleUpdateField(item.id, 'days', v != null ? parseInt(v as string) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + {pp != null ? fmt(pp, currency) : '-'}{pd != null ? fmt(pd, currency) : '-'}{ppd != null ? fmt(ppd, currency) : '-'} + {canEdit ? ( +
+ handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless /> +
+ ) : ( + {item.expense_date || '—'} + )} +
handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> + {canEdit && ( + + )} +
+
+
+ ) +} diff --git a/client/src/components/Budget/BudgetPanelInlineEditCell.tsx b/client/src/components/Budget/BudgetPanelInlineEditCell.tsx new file mode 100644 index 00000000..c74c8bf5 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelInlineEditCell.tsx @@ -0,0 +1,71 @@ +import { useState, useEffect, useRef } from 'react' + +interface InlineEditCellProps { + value: string | number | null | undefined + onSave: (value: string | number | null) => void + type?: 'text' | 'number' + style?: React.CSSProperties + placeholder?: string + decimals?: number + locale: string + editTooltip?: string + readOnly?: boolean +} + +export default function InlineEditCell({ value, onSave, type = 'text', style = {} as React.CSSProperties, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }: InlineEditCellProps) { + const [editing, setEditing] = useState(false) + const [editValue, setEditValue] = useState(value ?? '') + const inputRef = useRef(null) + + useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing]) + + const save = () => { + setEditing(false) + let v: string | number | null = editValue + if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p } + if (v !== value) onSave(v) + } + + const handlePaste = (e: React.ClipboardEvent) => { + if (type !== 'number') return + e.preventDefault() + let text = e.clipboardData.getData('text').trim() + // Strip everything except digits, dots, commas, minus + text = text.replace(/[^\d.,-]/g, '') + // Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal + const lastComma = text.lastIndexOf(',') + const lastDot = text.lastIndexOf('.') + const decimalPos = Math.max(lastComma, lastDot) + if (decimalPos > -1) { + const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '') + const decPart = text.substring(decimalPos + 1) + text = intPart + '.' + decPart + } else { + text = text.replace(/[.,]/g, '') + } + setEditValue(text) + } + + if (editing) { + return setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste} + onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }} + style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }} + placeholder={placeholder} /> + } + + const display = type === 'number' && value != null + ? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) + : (value || '') + + return ( +
{ if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip} + style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center', + justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s', + color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }} + onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }} + onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}> + {display || placeholder || '-'} +
+ ) +} diff --git a/client/src/components/Budget/BudgetPanelMemberChips.tsx b/client/src/components/Budget/BudgetPanelMemberChips.tsx new file mode 100644 index 00000000..c5d0c190 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelMemberChips.tsx @@ -0,0 +1,179 @@ +import ReactDOM from 'react-dom' +import { useState, useEffect, useRef, useCallback } from 'react' +import { Pencil, Users, Check } from 'lucide-react' +import type { BudgetItemMember } from '../../types' + +export interface TripMember { + id: number + username: string + avatar_url?: string | null +} + +// ── Chip with custom tooltip ───────────────────────────────────────────────── +interface ChipWithTooltipProps { + label: string + avatarUrl: string | null + size?: number + paid?: boolean + onClick?: () => void +} + +export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) { + const [hover, setHover] = useState(false) + const [pos, setPos] = useState({ top: 0, left: 0 }) + const ref = useRef(null) + + const onEnter = () => { + if (ref.current) { + const rect = ref.current.getBoundingClientRect() + setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 }) + } + setHover(true) + } + + const borderColor = paid ? '#22c55e' : 'var(--border-primary)' + const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)' + + return ( + <> +
setHover(false)} + onClick={onClick} + style={{ + width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, + background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', + fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', + overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', + transition: 'border-color 0.15s, background 0.15s', + }}> + {avatarUrl + ? + : label?.[0]?.toUpperCase() + } +
+ {hover && ReactDOM.createPortal( +
+ {label} + {paid && ( + Paid + )} +
, + document.body + )} + + ) +} + +// ── Budget Member Chips (for Persons column) ──────────────────────────────── +interface BudgetMemberChipsProps { + members?: BudgetItemMember[] + tripMembers?: TripMember[] + onSetMembers: (memberIds: number[]) => void + onTogglePaid?: (userId: number, paid: boolean) => void + compact?: boolean + readOnly?: boolean +} + +export default function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) { + const chipSize = compact ? 20 : 30 + const btnSize = compact ? 18 : 28 + const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14) + const [showDropdown, setShowDropdown] = useState(false) + const [dropPos, setDropPos] = useState({ top: 0, left: 0 }) + const btnRef = useRef(null) + const dropRef = useRef(null) + + const openDropdown = useCallback(() => { + if (btnRef.current) { + const rect = btnRef.current.getBoundingClientRect() + setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 }) + } + setShowDropdown(v => !v) + }, []) + + useEffect(() => { + if (!showDropdown) return + const close = (e: MouseEvent) => { + if (dropRef.current && dropRef.current.contains(e.target as Node)) return + if (btnRef.current && btnRef.current.contains(e.target as Node)) return + setShowDropdown(false) + } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [showDropdown]) + + const memberIds = members.map(m => m.user_id) + + const toggleMember = (userId: number) => { + const newIds = memberIds.includes(userId) + ? memberIds.filter(id => id !== userId) + : [...memberIds, userId] + onSetMembers(newIds) + } + + return ( +
+ {members.map(m => ( + onTogglePaid(m.user_id, !m.paid) : undefined} + /> + ))} + {!readOnly && ( + + )} + {showDropdown && ReactDOM.createPortal( +
+ {tripMembers.map(tm => { + const isActive = memberIds.includes(tm.id) + return ( + + ) + })} +
, + document.body + )} +
+ ) +} diff --git a/client/src/components/Budget/BudgetPanelPerPersonInline.tsx b/client/src/components/Budget/BudgetPanelPerPersonInline.tsx new file mode 100644 index 00000000..5e248502 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelPerPersonInline.tsx @@ -0,0 +1,64 @@ +import { useState, useEffect } from 'react' +import { budgetApi } from '../../api/client' +import type { BudgetItem } from '../../types' +import { fmtNum, colorForUserId, widgetTheme } from './BudgetPanel.helpers' +import RingAvatar from './BudgetPanelRingAvatar' + +interface PerPersonSummaryEntry { + user_id: number + username: string + avatar_url: string | null + total_assigned: number +} + +interface PerPersonInlineProps { + tripId: number + budgetItems: BudgetItem[] + currency: string + locale: string +} + +export default function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType }) { + const [data, setData] = useState(null) + const fmt = (v: number) => fmtNum(v, locale, currency) + + useEffect(() => { + budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {}) + }, [tripId, budgetItems]) + + if (!data || data.length === 0) return null + + const people = data.map(p => ({ ...p, color: colorForUserId(p.user_id) })) + + return ( + <> + {grandTotal > 0 && ( +
+ {people.map(p => ( +
+ ))} +
+ )} + +
+ {people.map(p => { + const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0 + return ( +
+ +
+
{p.username}
+
{percent}%
+
+
{fmt(p.total_assigned)}
+
+ ) + })} +
+ + ) +} diff --git a/client/src/components/Budget/BudgetPanelPieChart.tsx b/client/src/components/Budget/BudgetPanelPieChart.tsx new file mode 100644 index 00000000..a72964a8 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelPieChart.tsx @@ -0,0 +1,53 @@ +import { Wallet } from 'lucide-react' + +interface PieSegment { + label: string + value: number + color: string +} + +// ── Pie Chart (pure CSS conic-gradient) ────────────────────────────────────── +interface PieChartProps { + segments: PieSegment[] + size?: number + totalLabel: string +} + +export default function PieChart({ segments, size = 200, totalLabel }: PieChartProps) { + if (!segments.length) return null + + const total = segments.reduce((s, x) => s + x.value, 0) + if (total === 0) return null + + let cumDeg = 0 + const stops = segments.map(seg => { + const start = cumDeg + const deg = (seg.value / total) * 360 + cumDeg += deg + return `${seg.color} ${start}deg ${start + deg}deg` + }).join(', ') + + return ( +
+
+
+ + {totalLabel} +
+
+ ) +} diff --git a/client/src/components/Budget/BudgetPanelRingAvatar.tsx b/client/src/components/Budget/BudgetPanelRingAvatar.tsx new file mode 100644 index 00000000..585bfece --- /dev/null +++ b/client/src/components/Budget/BudgetPanelRingAvatar.tsx @@ -0,0 +1,22 @@ +import { colorForUserId } from './BudgetPanel.helpers' + +export default function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) { + const color = colorForUserId(userId) + return ( +
+
+ {avatarUrl ? : username?.[0]?.toUpperCase()} +
+
+ ) +} diff --git a/client/src/components/Budget/BudgetPanelSummary.tsx b/client/src/components/Budget/BudgetPanelSummary.tsx new file mode 100644 index 00000000..693ea986 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelSummary.tsx @@ -0,0 +1,280 @@ +import type { Dispatch, SetStateAction } from 'react' +import { Wallet, Info, ChevronDown, ChevronRight, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react' +import type { BudgetItem } from '../../types' +import { currencyDecimals } from '../../utils/formatters' +import { SYMBOLS } from './BudgetPanel.constants' +import { hexLighten, widgetTheme } from './BudgetPanel.helpers' +import RingAvatar from './BudgetPanelRingAvatar' +import PerPersonInline from './BudgetPanelPerPersonInline' +import type { SettlementData, PieSegment } from './useBudgetPanel' + +interface BudgetSummaryProps { + theme: ReturnType + currency: string + locale: string + grandTotal: number + hasMultipleMembers: boolean + budgetItems: BudgetItem[] + settlement: SettlementData | null + settlementOpen: boolean + setSettlementOpen: Dispatch> + pieSegments: PieSegment[] + isDark: boolean + tripId: number + t: (key: string) => string + fmt: (v: number | null | undefined, cur: string) => string +} + +export default function BudgetSummary({ theme, currency, locale, grandTotal, hasMultipleMembers, budgetItems, + settlement, settlementOpen, setSettlementOpen, pieSegments, isDark, tripId, t, fmt }: BudgetSummaryProps) { + return ( +
+ +
+
+
+ +
+
+
{t('budget.totalBudget')}
+
+
+ + {(() => { + const decimals = currencyDecimals(currency) + const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) + const sep = (0.1).toLocaleString(locale).replace(/\d/g, '') + const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, ''] + return ( +
+ {integerPart} + {decimalPart && {sep}{decimalPart}} + {SYMBOLS[currency] || currency} +
+ ) + })()} +
+ {currency} +
+ + {hasMultipleMembers && (budgetItems || []).some(i => (i.members?.length ?? 0) > 0) && ( + + )} + + {/* Settlement dropdown inside the total card */} + {hasMultipleMembers && settlement && settlement.flows.length > 0 && ( +
+ + + {settlementOpen && ( +
+ {settlement.flows.map((flow, i) => ( +
{ e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }} + onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }} + > + +
+ + {fmt(flow.amount, currency)} + +
+
+
+
+ +
+ ))} + + {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && ( +
+
+ {t('budget.netBalances')} +
+
+ {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => { + const positive = b.balance > 0 + const Trend = positive ? TrendingUp : TrendingDown + return ( +
+ + + {b.username} + + + + {positive ? '+' : ''}{fmt(b.balance, currency)} + +
+ ) + })} +
+
+ )} +
+ )} +
+ )} +
+ + {pieSegments.length > 0 && (() => { + const decimals = currencyDecimals(currency) + const total = pieSegments.reduce((s, x) => s + x.value, 0) + const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) + const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '') + const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, ''] + const R = 80 + const CIRC = 2 * Math.PI * R + let dashOffset = 0 + return ( +
+
+
+ +
+
+
{t('budget.byCategory')}
+
+
+ +
+ + + {pieSegments.map((seg, i) => { + const c2 = hexLighten(seg.color, 0.2) + return ( + + + + + ) + })} + + + {pieSegments.map((seg, i) => { + const segLen = total > 0 ? (seg.value / total) * CIRC : 0 + const circle = ( + + ) + dashOffset += segLen + return circle + })} + +
+
{t('budget.total')}
+
+ {totalInt} + {totalDec && {decimalSep}{totalDec}} +
+
{currency}
+
+
+ +
+ {pieSegments.map((seg, i) => { + const pct = total > 0 ? (seg.value / total) * 100 : 0 + const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%' + const c2 = hexLighten(seg.color, 0.2) + const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color + return ( +
e.currentTarget.style.background = theme.rowHover} + onMouseLeave={e => e.currentTarget.style.background = 'transparent'} + > +
+
+
{seg.name}
+
{fmt(seg.value, currency)}
+
+ {pctLabel} +
+ ) + })} +
+
+ ) + })()} + +
+ ) +} diff --git a/client/src/components/Budget/useBudgetPanel.ts b/client/src/components/Budget/useBudgetPanel.ts new file mode 100644 index 00000000..442bf037 --- /dev/null +++ b/client/src/components/Budget/useBudgetPanel.ts @@ -0,0 +1,211 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react' +import type { CSSProperties } from 'react' +import { useTripStore } from '../../store/tripStore' +import { useCanDo } from '../../store/permissionsStore' +import { useToast } from '../shared/Toast' +import { useTranslation } from '../../i18n' +import { budgetApi } from '../../api/client' +import type { BudgetItem } from '../../types' +import { currencyDecimals } from '../../utils/formatters' +import { widgetTheme, fmtNum, calcPP, calcPD, calcPPD } from './BudgetPanel.helpers' +import { PIE_COLORS } from './BudgetPanel.constants' +import type { TripMember } from './BudgetPanelMemberChips' + +function useIsDark(): boolean { + const [dark, setDark] = useState(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')) + useEffect(() => { + if (typeof document === 'undefined') return + const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark'))) + mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) + return () => mo.disconnect() + }, []) + return dark +} + +export interface EditingCat { + name: string + value: string +} + +interface SettlementPerson { + user_id: number + username: string + avatar_url: string | null +} + +interface SettlementFlow { + from: SettlementPerson + to: SettlementPerson + amount: number +} + +interface SettlementBalance { + user_id: number + username: string + avatar_url: string | null + balance: number +} + +export interface SettlementData { + balances: SettlementBalance[] + flows: SettlementFlow[] +} + +export interface PieSegment { + name: string + value: number + color: string +} + +export interface AddItemData { + name: string + total_price: number + persons: number | null + days: number | null + note: string | null + expense_date: string | null +} + +export function useBudgetPanel(tripId: number, tripMembers: TripMember[]) { + const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore() + const can = useCanDo() + const toast = useToast() + const { t, locale } = useTranslation() + const isDark = useIsDark() + const theme = useMemo(() => widgetTheme(isDark), [isDark]) + const [newCategoryName, setNewCategoryName] = useState('') + const [editingCat, setEditingCat] = useState(null) // { name, value } + const [settlement, setSettlement] = useState(null) + const [settlementOpen, setSettlementOpen] = useState(false) + const currency = trip?.currency || 'EUR' + const canEdit = can('budget_edit', trip) + + const fmt = (v: number | null | undefined, cur: string) => fmtNum(v, locale, cur) + const hasMultipleMembers = tripMembers.length > 1 + + // Drag state for categories + const [dragCat, setDragCat] = useState(null) + const [dragOverCat, setDragOverCat] = useState(null) + // Drag state for items within a category + const [dragItem, setDragItem] = useState(null) + const [dragOverItem, setDragOverItem] = useState(null) + const [dragItemCat, setDragItemCat] = useState(null) + + // Load settlement data whenever budget items change + useEffect(() => { + if (!hasMultipleMembers) return + budgetApi.settlement(tripId).then(setSettlement).catch(() => {}) + }, [tripId, budgetItems, hasMultipleMembers]) + + const setCurrency = (cur: string) => { + if (tripId) updateTrip(tripId, { currency: cur }) + } + + useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId]) + + const grouped = useMemo(() => { + const map = new Map() + for (const item of (budgetItems || [])) { + const cat = item.category || 'Other' + if (!map.has(cat)) map.set(cat, []) + map.get(cat)!.push(item) + } + return map + }, [budgetItems]) + + const categoryNames = Array.from(grouped.keys()) + + // Stable color mapping: assign index-based colors once, never reassign on reorder + const colorMapRef = useRef(new Map()) + const categoryColor = useCallback((cat: string) => { + const map = colorMapRef.current + if (!map.has(cat)) { + map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length]) + } + return map.get(cat)! + }, []) + const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0) + + const pieSegments = useMemo(() => + categoryNames.map((cat, i) => ({ + name: cat, + value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0), + color: categoryColor(cat), + })).filter(s => s.value > 0) + , [grouped, categoryNames]) + + const handleAddItem = async (category: string, data: AddItemData) => { try { await addBudgetItem(tripId, { ...data, category }) } catch { toast.error(t('common.error')) } } + const handleUpdateField = async (id: number, field: string, value: unknown) => { try { await updateBudgetItem(tripId, id, { [field]: value } as Partial) } catch { toast.error(t('common.error')) } } + const handleDeleteItem = async (id: number) => { try { await deleteBudgetItem(tripId, id) } catch { toast.error(t('common.error')) } } + const handleDeleteCategory = async (cat: string) => { + const items = grouped.get(cat) || [] + try { for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id) } + catch { toast.error(t('common.error')) } + } + const handleRenameCategory = async (oldName: string, newName: string) => { + if (!newName.trim() || newName.trim() === oldName) return + const items = grouped.get(oldName) || [] + try { for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) } + catch { toast.error(t('common.error')) } + } + const handleAddCategory = () => { + if (!newCategoryName.trim()) return + Promise.resolve(addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })) + .catch(() => toast.error(t('common.error'))) + setNewCategoryName('') + } + + const handleExportCsv = () => { + const sep = ';' + const esc = (v: unknown) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s } + const d = currencyDecimals(currency) + const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : '' + + const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } + const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note'] + const rows = [header.join(sep)] + + for (const cat of categoryNames) { + for (const item of (grouped.get(cat) || [])) { + const pp = calcPP(item.total_price, item.persons) + const pd = calcPD(item.total_price, item.days) + const ppd = calcPPD(item.total_price, item.persons, item.days) + rows.push([ + esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')), + fmtPrice(item.total_price), item.persons ?? '', item.days ?? '', + fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd), + esc(item.note || ''), + ].join(sep)) + } + } + + const bom = '' + const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim() + a.download = `budget-${safeName}.csv` + a.click() + URL.revokeObjectURL(url) + } + + const th: CSSProperties = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' } + const td: CSSProperties = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' } + + return { + trip, budgetItems, + setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories, + t, locale, isDark, theme, + newCategoryName, setNewCategoryName, + editingCat, setEditingCat, + settlement, settlementOpen, setSettlementOpen, + currency, canEdit, fmt, hasMultipleMembers, + dragCat, setDragCat, dragOverCat, setDragOverCat, + dragItem, setDragItem, dragOverItem, setDragOverItem, dragItemCat, setDragItemCat, + setCurrency, + grouped, categoryNames, categoryColor, grandTotal, pieSegments, + handleAddItem, handleUpdateField, handleDeleteItem, handleDeleteCategory, handleRenameCategory, handleAddCategory, handleExportCsv, + th, td, + } +} diff --git a/client/src/components/Collab/CollabChat.constants.ts b/client/src/components/Collab/CollabChat.constants.ts new file mode 100644 index 00000000..3b8794cd --- /dev/null +++ b/client/src/components/Collab/CollabChat.constants.ts @@ -0,0 +1,10 @@ +export const EMOJI_CATEGORIES = { + 'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'], + 'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'], + 'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'], +} + +// Reaction Quick Menu (right-click) +export const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉'] + +export const URL_REGEX = /https?:\/\/[^\s<>"']+/g diff --git a/client/src/components/Collab/CollabChat.helpers.ts b/client/src/components/Collab/CollabChat.helpers.ts new file mode 100644 index 00000000..84de232c --- /dev/null +++ b/client/src/components/Collab/CollabChat.helpers.ts @@ -0,0 +1,42 @@ +// ── Twemoji helper (Apple-style emojis via CDN) ── +export function emojiToCodepoint(emoji) { + const codepoints = [] + for (const c of emoji) { + const cp = c.codePointAt(0) + if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector + } + return codepoints.join('-') +} + +// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC +export function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) } + +export function formatTime(isoString, is12h) { + const d = parseUTC(isoString) + const h = d.getHours() + const mm = String(d.getMinutes()).padStart(2, '0') + if (is12h) { + const period = h >= 12 ? 'PM' : 'AM' + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h + return `${h12}:${mm} ${period}` + } + return `${String(h).padStart(2, '0')}:${mm}` +} + +export function formatDateSeparator(isoString, t) { + const d = parseUTC(isoString) + const now = new Date() + const yesterday = new Date(); yesterday.setDate(now.getDate() - 1) + + if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today' + if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday' + + return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) +} + +export function shouldShowDateSeparator(msg, prevMsg) { + if (!prevMsg) return true + const d1 = parseUTC(msg.created_at).toDateString() + const d2 = parseUTC(prevMsg.created_at).toDateString() + return d1 !== d2 +} diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index 8ee70371..94b7f1b0 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -1,351 +1,10 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react' import ReactDOM from 'react-dom' -import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react' -import { collabApi } from '../../api/client' -import { useSettingsStore } from '../../store/settingsStore' -import { useCanDo } from '../../store/permissionsStore' -import { useTripStore } from '../../store/tripStore' -import { addListener, removeListener } from '../../api/websocket' -import { useTranslation } from '../../i18n' -import { useToast } from '../shared/Toast' +import { ArrowUp, Reply, Smile, X } from 'lucide-react' import type { User } from '../../types' - -interface ChatReaction { - emoji: string - count: number - users: { id: number; username: string }[] -} - -interface ChatMessage { - id: number - trip_id: number - user_id: number - text: string - reply_to_id: number | null - reactions: ChatReaction[] - created_at: string - user?: { username: string; avatar_url: string | null } - reply_to?: ChatMessage | null -} - -// ── Twemoji helper (Apple-style emojis via CDN) ── -function emojiToCodepoint(emoji) { - const codepoints = [] - for (const c of emoji) { - const cp = c.codePointAt(0) - if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector - } - return codepoints.join('-') -} - -function TwemojiImg({ emoji, size = 20, style = {} }) { - const cp = emojiToCodepoint(emoji) - const [failed, setFailed] = useState(false) - - if (failed) { - return {emoji} - } - - return ( - {emoji} setFailed(true)} - /> - ) -} - -const EMOJI_CATEGORIES = { - 'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'], - 'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'], - 'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'], -} - -// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC -function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) } - -function formatTime(isoString, is12h) { - const d = parseUTC(isoString) - const h = d.getHours() - const mm = String(d.getMinutes()).padStart(2, '0') - if (is12h) { - const period = h >= 12 ? 'PM' : 'AM' - const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h - return `${h12}:${mm} ${period}` - } - return `${String(h).padStart(2, '0')}:${mm}` -} - -function formatDateSeparator(isoString, t) { - const d = parseUTC(isoString) - const now = new Date() - const yesterday = new Date(); yesterday.setDate(now.getDate() - 1) - - if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today' - if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday' - - return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) -} - -function shouldShowDateSeparator(msg, prevMsg) { - if (!prevMsg) return true - const d1 = parseUTC(msg.created_at).toDateString() - const d2 = parseUTC(prevMsg.created_at).toDateString() - return d1 !== d2 -} - -/* ── Emoji Picker ── */ -interface EmojiPickerProps { - onSelect: (emoji: string) => void - onClose: () => void - anchorRef: React.RefObject - containerRef: React.RefObject -} - -function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) { - const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0]) - const ref = useRef(null) - - const getPos = () => { - const container = containerRef?.current - const anchor = anchorRef?.current - if (container && anchor) { - const cRect = container.getBoundingClientRect() - const aRect = anchor.getBoundingClientRect() - return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 } - } - return { bottom: 80, left: 0 } - } - const pos = getPos() - - useEffect(() => { - const close = (e) => { - if (ref.current && ref.current.contains(e.target)) return - if (anchorRef?.current && anchorRef.current.contains(e.target)) return - onClose() - } - document.addEventListener('mousedown', close) - return () => document.removeEventListener('mousedown', close) - }, [onClose, anchorRef]) - - return ReactDOM.createPortal( -
- {/* Category tabs */} -
- {Object.keys(EMOJI_CATEGORIES).map(c => ( - - ))} -
- {/* Emoji grid */} -
- {EMOJI_CATEGORIES[cat].map((emoji, i) => ( - - ))} -
-
, - document.body - ) -} - -/* ── Reaction Quick Menu (right-click) ── */ -const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉'] - -interface ReactionMenuProps { - x: number - y: number - onReact: (emoji: string) => void - onClose: () => void -} - -function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) { - const ref = useRef(null) - - useEffect(() => { - const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() } - document.addEventListener('mousedown', close) - return () => document.removeEventListener('mousedown', close) - }, [onClose]) - - // Clamp to viewport - const menuWidth = 156 - const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8)) - - return ( -
- {QUICK_REACTIONS.map(emoji => ( - - ))} -
- ) -} - -/* ── Message Text with clickable URLs ── */ -interface MessageTextProps { - text: string -} - -function MessageText({ text }: MessageTextProps) { - const parts = text.split(URL_REGEX) - const urls = text.match(URL_REGEX) || [] - const result = [] - parts.forEach((part, i) => { - if (part) result.push(part) - if (urls[i]) result.push( - - {urls[i]} - - ) - }) - return <>{result} -} - -/* ── Link Preview ── */ -const URL_REGEX = /https?:\/\/[^\s<>"']+/g -const previewCache = {} - -interface LinkPreviewProps { - url: string - tripId: number - own: boolean - onLoad: (() => void) | undefined -} - -function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) { - const [data, setData] = useState(previewCache[url] || null) - const [loading, setLoading] = useState(!previewCache[url]) - - useEffect(() => { - if (previewCache[url]) return - collabApi.linkPreview(tripId, url).then(d => { - previewCache[url] = d - setData(d) - setLoading(false) - if (d?.title || d?.description || d?.image) onLoad?.() - }).catch(() => setLoading(false)) - }, [url, tripId]) - - if (loading || !data || (!data.title && !data.description && !data.image)) return null - - const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })() - - return ( - e.currentTarget.style.opacity = '0.85'} - onMouseLeave={e => e.currentTarget.style.opacity = '1'} - > - {data.image && ( - e.currentTarget.style.display = 'none'} /> - )} -
- {domain && ( -
- {data.site_name || domain} -
- )} - {data.title && ( -
- {data.title} -
- )} - {data.description && ( -
- {data.description} -
- )} -
-
- ) -} - -/* ── Reaction Badge with NOMAD tooltip ── */ -interface ReactionBadgeProps { - reaction: ChatReaction - currentUserId: number - onReact: () => void -} - -function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) { - const [hover, setHover] = useState(false) - const [pos, setPos] = useState({ top: 0, left: 0 }) - const ref = useRef(null) - const names = reaction.users.map(u => u.username).join(', ') - - return ( - <> - - {hover && names && ReactDOM.createPortal( -
- {names} -
, - document.body - )} - - ) -} +import { useCollabChat } from './useCollabChat' +import { ChatMessages } from './CollabChatMessages' +import { EmojiPicker } from './CollabChatEmojiPicker' +import { ReactionMenu } from './CollabChatReactionMenu' /* ── Main Component ── */ interface CollabChatProps { @@ -445,417 +104,3 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
) } - -function useCollabChat(tripId: any, currentUser: any) { - const { t } = useTranslation() - const toast = useToast() - const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const can = useCanDo() - const trip = useTripStore((s) => s.trip) - const canEdit = can('collab_edit', trip) - - const [messages, setMessages] = useState([]) - const [loading, setLoading] = useState(true) - const [hasMore, setHasMore] = useState(false) - const [loadingMore, setLoadingMore] = useState(false) - const [text, setText] = useState('') - const [replyTo, setReplyTo] = useState(null) - const [hoveredId, setHoveredId] = useState(null) - const [sending, setSending] = useState(false) - const [showEmoji, setShowEmoji] = useState(false) - const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y } - const [deletingIds, setDeletingIds] = useState(new Set()) - const deleteTimersRef = useRef[]>([]) - - useEffect(() => { - return () => { deleteTimersRef.current.forEach(clearTimeout) } - }, []) - - const containerRef = useRef(null) - const messagesRef = useRef(messages) - messagesRef.current = messages - const scrollRef = useRef(null) - const textareaRef = useRef(null) - const emojiBtnRef = useRef(null) - const isAtBottom = useRef(true) - - const scrollToBottom = useCallback((behavior = 'auto') => { - const el = scrollRef.current - if (!el) return - requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior })) - }, []) - - const checkAtBottom = useCallback(() => { - const el = scrollRef.current - if (!el) return - isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48 - }, []) - - /* ── load messages ── */ - useEffect(() => { - let cancelled = false - setLoading(true) - collabApi.getMessages(tripId).then(data => { - if (cancelled) return - const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m) - setMessages(msgs) - setHasMore(msgs.length >= 100) - setLoading(false) - setTimeout(() => scrollToBottom(), 30) - }).catch(() => { if (!cancelled) setLoading(false) }) - return () => { cancelled = true } - }, [tripId, scrollToBottom]) - - /* ── load more ── */ - const handleLoadMore = useCallback(async () => { - if (loadingMore || messages.length === 0) return - setLoadingMore(true) - const el = scrollRef.current - const prevHeight = el ? el.scrollHeight : 0 - try { - const data = await collabApi.getMessages(tripId, messages[0]?.id) - const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m) - if (older.length === 0) { setHasMore(false) } - else { - setMessages(prev => [...older, ...prev]) - setHasMore(older.length >= 100) - requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight }) - } - } catch {} finally { setLoadingMore(false) } - }, [tripId, loadingMore, messages]) - - /* ── websocket ── */ - useEffect(() => { - const handler = (event) => { - if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) { - setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message]) - if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30) - } - if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) { - setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m)) - if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) - } - if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) { - setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m)) - } - } - addListener(handler) - return () => removeListener(handler) - }, [tripId, scrollToBottom]) - - /* ── auto-resize textarea ── */ - const handleTextChange = useCallback((e) => { - setText(e.target.value) - const ta = textareaRef.current - if (ta) { - ta.style.height = 'auto' - const h = Math.min(ta.scrollHeight, 100) - ta.style.height = h + 'px' - ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden' - } - }, []) - - /* ── send ── */ - const handleSend = useCallback(async () => { - const body = text.trim() - if (!body || sending) return - setSending(true) - try { - const payload: { text: string; reply_to?: number } = { text: body } - if (replyTo) payload.reply_to = replyTo.id - const data = await collabApi.sendMessage(tripId, payload) - if (data?.message) { - setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message]) - } - setText(''); setReplyTo(null); setShowEmoji(false) - if (textareaRef.current) textareaRef.current.style.height = 'auto' - isAtBottom.current = true - setTimeout(() => scrollToBottom('smooth'), 50) - } catch { toast.error(t('common.error')) } finally { setSending(false) } - }, [text, sending, replyTo, tripId, scrollToBottom, toast, t]) - - const handleKeyDown = useCallback((e) => { - if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() } - }, [handleSend]) - - const handleDelete = useCallback(async (msgId) => { - const msg = messages.find(m => m.id === msgId) - requestAnimationFrame(() => { - setDeletingIds(prev => new Set(prev).add(msgId)) - }) - const timer = setTimeout(async () => { - try { - await collabApi.deleteMessage(tripId, msgId) - setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m)) - } catch { toast.error(t('common.error')) } - setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s }) - }, 400) - deleteTimersRef.current.push(timer) - }, [tripId, toast, t]) - - const handleReact = useCallback(async (msgId, emoji) => { - setReactMenu(null) - try { - const data = await collabApi.reactMessage(tripId, msgId, emoji) - setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m)) - } catch { toast.error(t('common.error')) } - }, [tripId, toast, t]) - - const handleEmojiSelect = useCallback((emoji) => { - setText(prev => prev + emoji) - textareaRef.current?.focus() - }, []) - - const isOwn = (msg) => String(msg.user_id) === String(currentUser.id) - - // Check if message is only emoji (1-3 emojis, no other text) - const isEmojiOnly = (text) => { - const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u - return emojiRegex.test(text.trim()) - } - - return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } -} - -function ChatMessages(props: any) { - const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props - return ( - <> - {/* Messages */} - {messages.length === 0 ? ( -
- - {t('collab.chat.empty')} - {t('collab.chat.emptyDesc') || ''} -
- ) : ( -
- {hasMore && ( -
- -
- )} - - {messages.map((msg, idx) => { - const own = isOwn(msg) - const prevMsg = messages[idx - 1] - const nextMsg = messages[idx + 1] - const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id) - const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id) - const showDate = shouldShowDateSeparator(msg, prevMsg) - const showAvatar = !own && isLastInGroup - const bigEmoji = isEmojiOnly(msg.text) - const hasReply = msg.reply_text || msg.reply_to - // Deleted message placeholder - if (msg._deleted) { - return ( - - {showDate && ( -
- - {formatDateSeparator(msg.created_at, t)} - -
- )} -
- - {msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)} - -
-
- ) - } - - // Bubble border radius — iMessage style tails - const br = own - ? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px` - : `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}` - - return ( - - {/* Date separator */} - {showDate && ( -
- - {formatDateSeparator(msg.created_at, t)} - -
- )} - -
- {/* Avatar slot for others */} - {!own && ( -
- {showAvatar && ( - msg.user_avatar ? ( - - ) : ( -
- {(msg.username || '?')[0].toUpperCase()} -
- ) - )} -
- )} - -
- {/* Username for others at group start */} - {!own && isNewGroup && ( - - {msg.username} - - )} - - {/* Bubble */} -
setHoveredId(msg.id)} - onMouseLeave={() => setHoveredId(null)} - onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }} - onTouchEnd={e => { - const now = Date.now() - const lastTap = Number(e.currentTarget.dataset.lastTap) || 0 - if (now - lastTap < 300 && canEdit) { - e.preventDefault() - const touch = e.changedTouches?.[0] - if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY }) - } - e.currentTarget.dataset.lastTap = String(now) - }} - > - {bigEmoji ? ( -
- {msg.text} -
- ) : ( -
- {/* Inline reply quote */} - {hasReply && ( -
-
- {msg.reply_username || ''} -
-
- {(msg.reply_text || '').slice(0, 80)} -
-
- )} - {hasReply ? ( -
- ) : } - {(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => ( - { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} /> - ))} -
- )} - - {/* Hover actions */} -
- - {own && canEdit && ( - - )} -
-
- - {/* Reactions — iMessage style floating badge */} - {msg.reactions?.length > 0 && ( -
-
- {msg.reactions.map(r => { - const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id)) - return ( - { if (canEdit) handleReact(msg.id, r.emoji) }} /> - ) - })} -
-
- )} - - {/* Timestamp — only on last message of group */} - {isLastInGroup && ( - - {formatTime(msg.created_at, is12h)} - - )} -
-
-
- ) - })} -
- )} - - - ) -} diff --git a/client/src/components/Collab/CollabChat.types.ts b/client/src/components/Collab/CollabChat.types.ts new file mode 100644 index 00000000..5a059c53 --- /dev/null +++ b/client/src/components/Collab/CollabChat.types.ts @@ -0,0 +1,17 @@ +export interface ChatReaction { + emoji: string + count: number + users: { id: number; username: string }[] +} + +export interface ChatMessage { + id: number + trip_id: number + user_id: number + text: string + reply_to_id: number | null + reactions: ChatReaction[] + created_at: string + user?: { username: string; avatar_url: string | null } + reply_to?: ChatMessage | null +} diff --git a/client/src/components/Collab/CollabChatEmojiPicker.tsx b/client/src/components/Collab/CollabChatEmojiPicker.tsx new file mode 100644 index 00000000..67d1b310 --- /dev/null +++ b/client/src/components/Collab/CollabChatEmojiPicker.tsx @@ -0,0 +1,76 @@ +import React, { useState, useEffect, useRef } from 'react' +import ReactDOM from 'react-dom' +import { EMOJI_CATEGORIES } from './CollabChat.constants' +import { TwemojiImg } from './CollabChatTwemojiImg' + +/* ── Emoji Picker ── */ +interface EmojiPickerProps { + onSelect: (emoji: string) => void + onClose: () => void + anchorRef: React.RefObject + containerRef: React.RefObject +} + +export function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) { + const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0]) + const ref = useRef(null) + + const getPos = () => { + const container = containerRef?.current + const anchor = anchorRef?.current + if (container && anchor) { + const cRect = container.getBoundingClientRect() + const aRect = anchor.getBoundingClientRect() + return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 } + } + return { bottom: 80, left: 0 } + } + const pos = getPos() + + useEffect(() => { + const close = (e) => { + if (ref.current && ref.current.contains(e.target)) return + if (anchorRef?.current && anchorRef.current.contains(e.target)) return + onClose() + } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [onClose, anchorRef]) + + return ReactDOM.createPortal( +
+ {/* Category tabs */} +
+ {Object.keys(EMOJI_CATEGORIES).map(c => ( + + ))} +
+ {/* Emoji grid */} +
+ {EMOJI_CATEGORIES[cat].map((emoji, i) => ( + + ))} +
+
, + document.body + ) +} diff --git a/client/src/components/Collab/CollabChatLinkPreview.tsx b/client/src/components/Collab/CollabChatLinkPreview.tsx new file mode 100644 index 00000000..70d282b2 --- /dev/null +++ b/client/src/components/Collab/CollabChatLinkPreview.tsx @@ -0,0 +1,65 @@ +import { useState, useEffect } from 'react' +import { collabApi } from '../../api/client' + +/* ── Link Preview ── */ +const previewCache = {} + +interface LinkPreviewProps { + url: string + tripId: number + own: boolean + onLoad: (() => void) | undefined +} + +export function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) { + const [data, setData] = useState(previewCache[url] || null) + const [loading, setLoading] = useState(!previewCache[url]) + + useEffect(() => { + if (previewCache[url]) return + collabApi.linkPreview(tripId, url).then(d => { + previewCache[url] = d + setData(d) + setLoading(false) + if (d?.title || d?.description || d?.image) onLoad?.() + }).catch(() => setLoading(false)) + }, [url, tripId]) + + if (loading || !data || (!data.title && !data.description && !data.image)) return null + + const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })() + + return ( + e.currentTarget.style.opacity = '0.85'} + onMouseLeave={e => e.currentTarget.style.opacity = '1'} + > + {data.image && ( + e.currentTarget.style.display = 'none'} /> + )} +
+ {domain && ( +
+ {data.site_name || domain} +
+ )} + {data.title && ( +
+ {data.title} +
+ )} + {data.description && ( +
+ {data.description} +
+ )} +
+
+ ) +} diff --git a/client/src/components/Collab/CollabChatMessageText.tsx b/client/src/components/Collab/CollabChatMessageText.tsx new file mode 100644 index 00000000..795cf735 --- /dev/null +++ b/client/src/components/Collab/CollabChatMessageText.tsx @@ -0,0 +1,21 @@ +import { URL_REGEX } from './CollabChat.constants' + +/* ── Message Text with clickable URLs ── */ +interface MessageTextProps { + text: string +} + +export function MessageText({ text }: MessageTextProps) { + const parts = text.split(URL_REGEX) + const urls = text.match(URL_REGEX) || [] + const result = [] + parts.forEach((part, i) => { + if (part) result.push(part) + if (urls[i]) result.push( + + {urls[i]} + + ) + }) + return <>{result} +} diff --git a/client/src/components/Collab/CollabChatMessages.tsx b/client/src/components/Collab/CollabChatMessages.tsx new file mode 100644 index 00000000..75bec19d --- /dev/null +++ b/client/src/components/Collab/CollabChatMessages.tsx @@ -0,0 +1,250 @@ +import React from 'react' +import { Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react' +import { URL_REGEX } from './CollabChat.constants' +import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers' +import { MessageText } from './CollabChatMessageText' +import { LinkPreview } from './CollabChatLinkPreview' +import { ReactionBadge } from './CollabChatReactionBadge' + +export function ChatMessages(props: any) { + const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props + return ( + <> + {/* Messages */} + {messages.length === 0 ? ( +
+ + {t('collab.chat.empty')} + {t('collab.chat.emptyDesc') || ''} +
+ ) : ( +
+ {hasMore && ( +
+ +
+ )} + + {messages.map((msg, idx) => { + const own = isOwn(msg) + const prevMsg = messages[idx - 1] + const nextMsg = messages[idx + 1] + const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id) + const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id) + const showDate = shouldShowDateSeparator(msg, prevMsg) + const showAvatar = !own && isLastInGroup + const bigEmoji = isEmojiOnly(msg.text) + const hasReply = msg.reply_text || msg.reply_to + // Deleted message placeholder + if (msg._deleted) { + return ( + + {showDate && ( +
+ + {formatDateSeparator(msg.created_at, t)} + +
+ )} +
+ + {msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)} + +
+
+ ) + } + + // Bubble border radius — iMessage style tails + const br = own + ? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px` + : `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}` + + return ( + + {/* Date separator */} + {showDate && ( +
+ + {formatDateSeparator(msg.created_at, t)} + +
+ )} + +
+ {/* Avatar slot for others */} + {!own && ( +
+ {showAvatar && ( + msg.user_avatar ? ( + + ) : ( +
+ {(msg.username || '?')[0].toUpperCase()} +
+ ) + )} +
+ )} + +
+ {/* Username for others at group start */} + {!own && isNewGroup && ( + + {msg.username} + + )} + + {/* Bubble */} +
setHoveredId(msg.id)} + onMouseLeave={() => setHoveredId(null)} + onContextMenu={e => { e.preventDefault(); if (canEdit) setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }} + onTouchEnd={e => { + const now = Date.now() + const lastTap = Number(e.currentTarget.dataset.lastTap) || 0 + if (now - lastTap < 300 && canEdit) { + e.preventDefault() + const touch = e.changedTouches?.[0] + if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY }) + } + e.currentTarget.dataset.lastTap = String(now) + }} + > + {bigEmoji ? ( +
+ {msg.text} +
+ ) : ( +
+ {/* Inline reply quote */} + {hasReply && ( +
+
+ {msg.reply_username || ''} +
+
+ {(msg.reply_text || '').slice(0, 80)} +
+
+ )} + {hasReply ? ( +
+ ) : } + {(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => ( + { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} /> + ))} +
+ )} + + {/* Hover actions */} +
+ + {own && canEdit && ( + + )} +
+
+ + {/* Reactions — iMessage style floating badge */} + {msg.reactions?.length > 0 && ( +
+
+ {msg.reactions.map(r => { + const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id)) + return ( + { if (canEdit) handleReact(msg.id, r.emoji) }} /> + ) + })} +
+
+ )} + + {/* Timestamp — only on last message of group */} + {isLastInGroup && ( + + {formatTime(msg.created_at, is12h)} + + )} +
+
+
+ ) + })} +
+ )} + + + ) +} diff --git a/client/src/components/Collab/CollabChatReactionBadge.tsx b/client/src/components/Collab/CollabChatReactionBadge.tsx new file mode 100644 index 00000000..43122137 --- /dev/null +++ b/client/src/components/Collab/CollabChatReactionBadge.tsx @@ -0,0 +1,53 @@ +import { useState, useRef } from 'react' +import ReactDOM from 'react-dom' +import { TwemojiImg } from './CollabChatTwemojiImg' +import type { ChatReaction } from './CollabChat.types' + +/* ── Reaction Badge with NOMAD tooltip ── */ +interface ReactionBadgeProps { + reaction: ChatReaction + currentUserId: number + onReact: () => void +} + +export function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) { + const [hover, setHover] = useState(false) + const [pos, setPos] = useState({ top: 0, left: 0 }) + const ref = useRef(null) + const names = reaction.users.map(u => u.username).join(', ') + + return ( + <> + + {hover && names && ReactDOM.createPortal( +
+ {names} +
, + document.body + )} + + ) +} diff --git a/client/src/components/Collab/CollabChatReactionMenu.tsx b/client/src/components/Collab/CollabChatReactionMenu.tsx new file mode 100644 index 00000000..57e74a1e --- /dev/null +++ b/client/src/components/Collab/CollabChatReactionMenu.tsx @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react' +import { QUICK_REACTIONS } from './CollabChat.constants' +import { TwemojiImg } from './CollabChatTwemojiImg' + +/* ── Reaction Quick Menu (right-click) ── */ +interface ReactionMenuProps { + x: number + y: number + onReact: (emoji: string) => void + onClose: () => void +} + +export function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) { + const ref = useRef(null) + + useEffect(() => { + const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [onClose]) + + // Clamp to viewport + const menuWidth = 156 + const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8)) + + return ( +
+ {QUICK_REACTIONS.map(emoji => ( + + ))} +
+ ) +} diff --git a/client/src/components/Collab/CollabChatTwemojiImg.tsx b/client/src/components/Collab/CollabChatTwemojiImg.tsx new file mode 100644 index 00000000..6e29538e --- /dev/null +++ b/client/src/components/Collab/CollabChatTwemojiImg.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react' +import { emojiToCodepoint } from './CollabChat.helpers' + +export function TwemojiImg({ emoji, size = 20, style = {} }) { + const cp = emojiToCodepoint(emoji) + const [failed, setFailed] = useState(false) + + if (failed) { + return {emoji} + } + + return ( + {emoji} setFailed(true)} + /> + ) +} diff --git a/client/src/components/Collab/CollabNotes.constants.ts b/client/src/components/Collab/CollabNotes.constants.ts new file mode 100644 index 00000000..bf6e53cd --- /dev/null +++ b/client/src/components/Collab/CollabNotes.constants.ts @@ -0,0 +1,10 @@ +export const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" + +export const NOTE_COLORS = [ + { value: '#6366f1', label: 'Indigo' }, + { value: '#ef4444', label: 'Red' }, + { value: '#f59e0b', label: 'Amber' }, + { value: '#10b981', label: 'Emerald' }, + { value: '#3b82f6', label: 'Blue' }, + { value: '#8b5cf6', label: 'Violet' }, +] diff --git a/client/src/components/Collab/CollabNotes.helpers.ts b/client/src/components/Collab/CollabNotes.helpers.ts new file mode 100644 index 00000000..add4605c --- /dev/null +++ b/client/src/components/Collab/CollabNotes.helpers.ts @@ -0,0 +1,16 @@ +// Pure formatting helper for note timestamps. Falls back to translated +// relative labels for recent timestamps and a localized short date beyond a week. +export const formatTimestamp = (ts, t, locale) => { + if (!ts) return '' + const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z') + const now = new Date() + const diffMs = now.getTime() - d.getTime() + const diffMins = Math.floor(diffMs / 60000) + if (diffMins < 1) return t('collab.chat.justNow') || 'just now' + if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago` + const diffHrs = Math.floor(diffMins / 60) + if (diffHrs < 24) return t('collab.chat.hoursAgo', { n: diffHrs }) || `${diffHrs}h ago` + const diffDays = Math.floor(diffHrs / 24) + if (diffDays < 7) return t('collab.notes.daysAgo', { n: diffDays }) || `${diffDays}d ago` + return d.toLocaleDateString(locale || undefined, { month: 'short', day: 'numeric' }) +} diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index bd0d79d7..e19bce3b 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -1,908 +1,23 @@ -import ReactDOM from 'react-dom' -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' -import DOM from 'react-dom' +import { useState, useEffect, useCallback, useMemo } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' -import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react' +import ReactDOM from 'react-dom' +import { Plus, Pencil, X, StickyNote, Settings } from 'lucide-react' import { collabApi } from '../../api/client' -import { getAuthUrl } from '../../api/authUrl' -import { openFile } from '../../utils/fileDownload' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { addListener, removeListener } from '../../api/websocket' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import type { User } from '../../types' - -interface NoteFile { - id: number - filename: string - original_name: string - mime_type: string - file_size?: number | null - url?: string -} - -interface CollabNote { - id: number - trip_id: number - title: string - content: string - category: string - website: string | null - pinned: boolean - color: string | null - username: string - avatar_url: string | null - avatar: string | null - user_id: number - created_at: string - author?: { username: string; avatar: string | null } - user?: { username: string; avatar: string | null } - files?: NoteFile[] - // Wire field: collabService embeds note files as `attachments` (with url). - attachments?: NoteFile[] -} - -interface NoteAuthor { - username: string - avatar?: string | null -} - -const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" - -// ── Website Thumbnail (fetches OG image) ──────────────────────────────────── -const ogCache = {} - -interface WebsiteThumbnailProps { - url: string - tripId: number - color: string -} - -function WebsiteThumbnail({ url, tripId, color }: WebsiteThumbnailProps) { - const [data, setData] = useState(ogCache[url] || null) - const [failed, setFailed] = useState(false) - - useEffect(() => { - if (ogCache[url]) { setData(ogCache[url]); return } - collabApi.linkPreview(tripId, url).then(d => { ogCache[url] = d; setData(d) }).catch(() => setFailed(true)) - }, [url, tripId]) - - const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return 'link' } })() - - return ( - { e.currentTarget.style.transform = 'scale(1.08)'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' }} - onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.boxShadow = 'none' }}> - {data?.image && !failed ? ( - setFailed(true)} /> - ) : ( - <> - - - {domain} - - - )} - - ) -} - -// ── File Preview Portal ───────────────────────────────────────────────────── -interface FilePreviewPortalProps { - file: NoteFile | null - onClose: () => void -} - -function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { - const [authUrl, setAuthUrl] = useState('') - const rawUrl = file?.url || '' - useEffect(() => { - setAuthUrl('') - if (!rawUrl) return - getAuthUrl(rawUrl, 'download').then(setAuthUrl) - }, [rawUrl]) - - if (!file) return null - const isImage = file.mime_type?.startsWith('image/') - const isPdf = file.mime_type === 'application/pdf' - const isTxt = file.mime_type?.startsWith('text/') - - const openInNewTab = () => openFile(rawUrl).catch(() => {}) - - return ReactDOM.createPortal( -
- {isImage ? ( - /* Image lightbox — floating controls */ -
e.stopPropagation()}> - {authUrl - ? {file.original_name} - : - } -
- {file.original_name} -
- - -
-
-
- ) : ( - /* Document viewer — card with header */ -
e.stopPropagation()}> -
- {file.original_name} -
- - -
-
- {(isPdf || isTxt) ? ( - -

- -

-
- ) : ( -
- -
- )} -
- )} -
, - document.body - ) -} - -function AuthedImg({ src, style, onClick, onMouseEnter, onMouseLeave, alt }: { src: string; style?: React.CSSProperties; onClick?: () => void; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; alt?: string }) { - const [authSrc, setAuthSrc] = useState('') - useEffect(() => { - getAuthUrl(src, 'download').then(setAuthSrc) - }, [src]) - return authSrc ? {alt} : null -} - -const NOTE_COLORS = [ - { value: '#6366f1', label: 'Indigo' }, - { value: '#ef4444', label: 'Red' }, - { value: '#f59e0b', label: 'Amber' }, - { value: '#10b981', label: 'Emerald' }, - { value: '#3b82f6', label: 'Blue' }, - { value: '#8b5cf6', label: 'Violet' }, -] - -const formatTimestamp = (ts, t, locale) => { - if (!ts) return '' - const d = new Date(ts.endsWith?.('Z') ? ts : ts + 'Z') - const now = new Date() - const diffMs = now.getTime() - d.getTime() - const diffMins = Math.floor(diffMs / 60000) - if (diffMins < 1) return t('collab.chat.justNow') || 'just now' - if (diffMins < 60) return t('collab.chat.minutesAgo', { n: diffMins }) || `${diffMins}m ago` - const diffHrs = Math.floor(diffMins / 60) - if (diffHrs < 24) return t('collab.chat.hoursAgo', { n: diffHrs }) || `${diffHrs}h ago` - const diffDays = Math.floor(diffHrs / 24) - if (diffDays < 7) return t('collab.notes.daysAgo', { n: diffDays }) || `${diffDays}d ago` - return d.toLocaleDateString(locale || undefined, { month: 'short', day: 'numeric' }) -} - -// ── Avatar ────────────────────────────────────────────────────────────────── -interface UserAvatarProps { - user: NoteAuthor | null - size?: number -} - -function UserAvatar({ user, size = 14 }: UserAvatarProps) { - if (!user) return null - if (user.avatar) { - return ( - {user.username} - ) - } - const initials = (user.username || '?').slice(0, 1) - return ( -
- {initials} -
- ) -} - -// ── New Note Modal (portal to body) ───────────────────────────────────────── -interface NoteFormModalProps { - onClose: () => void - onSubmit: (data: { title: string; content: string; category: string | null; website: string | null; color?: string | null; _pendingFiles?: File[]; files?: File[] }) => Promise - onDeleteFile?: (noteId: number, fileId: number) => Promise - existingCategories: string[] - categoryColors: Record - getCategoryColor: (category: string) => string - note: CollabNote | null - tripId: number - t: (key: string) => string -} - -function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, categoryColors, getCategoryColor, note, tripId, t }: NoteFormModalProps) { - const can = useCanDo() - const tripObj = useTripStore((s) => s.trip) - const canUploadFiles = can('file_upload', tripObj) - const isEdit = !!note - const allCategories = [...new Set([...existingCategories, ...Object.keys(categoryColors || {})])].filter(Boolean) - - const [title, setTitle] = useState(note?.title || '') - const [content, setContent] = useState(note?.content || '') - const [category, setCategory] = useState(note?.category || allCategories[0] || '') - const [website, setWebsite] = useState(note?.website || '') - const [pendingFiles, setPendingFiles] = useState([]) - const [existingAttachments, setExistingAttachments] = useState(note?.attachments || []) - const [submitting, setSubmitting] = useState(false) - const fileRef = useRef(null) - - const finalCategory = category - - const handleSubmit = async (e) => { - e.preventDefault() - if (!title.trim()) return - setSubmitting(true) - try { - await onSubmit({ - title: title.trim(), - content: content.trim(), - category: finalCategory || null, - color: getCategoryColor(finalCategory), - website: website.trim() || null, - _pendingFiles: pendingFiles, - }) - onClose() - } catch { - } finally { - setSubmitting(false) - } - } - - const handleDeleteAttachment = async (fileId) => { - if (onDeleteFile && note) { - await onDeleteFile(note.id, fileId) - setExistingAttachments(prev => prev.filter(a => a.id !== fileId)) - } - } - - const canSubmit = title.trim() && !submitting - - return ReactDOM.createPortal( -
-
e.stopPropagation()} - onPaste={e => { - if (!canUploadFiles) return - const items = e.clipboardData?.items - if (!items) return - for (const item of Array.from(items)) { - if (item.type.startsWith('image/') || item.type === 'application/pdf') { - e.preventDefault() - const file = item.getAsFile() - if (file) setPendingFiles(prev => [...prev, file]) - return - } - } - }} - onSubmit={handleSubmit} - > - {/* Modal header */} -
-

- {isEdit ? t('collab.notes.edit') : t('collab.notes.new')} -

- -
- - {/* Modal body */} -
- {/* Title */} -
-
- {t('collab.notes.title')} -
- setTitle(e.target.value)} - placeholder={t('collab.notes.titlePlaceholder')} - style={{ - width: '100%', - border: '1px solid var(--border-primary)', - borderRadius: 10, - padding: '8px 12px', - fontSize: 13, - background: 'var(--bg-input)', - color: 'var(--text-primary)', - fontFamily: 'inherit', - outline: 'none', - boxSizing: 'border-box', - }} - /> -
- - {/* Content */} -
-
- {t('collab.notes.contentPlaceholder')} -
-