🚀

TRIPSPLIT · 出遊記帳

智慧出遊分帳 · 雙幣私人管理系統

旅伴免密碼躍遷通道

同行旅伴由專屬 QR Code / 邀請連結 進入時,系統將自動解鎖直達出遊記帳系統,無須手動輸入!

: (CURRENCY_CONFIG[e.currency] && CURRENCY_CONFIG[e.currency].symbol ? CURRENCY_CONFIG[e.currency].symbol : '¥'); const originalText = itemSymbol + ' ' + e.originalAmount.toLocaleString(); const twdText = e.currency === 'TWD' ? '' : ('(約 NT$ ' + e.twdAmount.toLocaleString() + ')'); const splitCount = e.splitWith ? e.splitWith.length : members.length; const dayText = e.day ? ('Day ' + e.day) : '全域'; return `
${dayText} #${idx + 1} ${e.title}
墊款人:${payer.name} · 分攤:${splitCount} 人平分
${originalText} ${twdText}
${e.receiptImg ? `
發票小票 收據證明
` : '無附收據'}
`; }).join(''); // 打開純淨可列印的 PDF 預覽視窗 const printWindow = window.open('', '_blank'); if (!printWindow) { alert('請允許瀏覽器彈出視窗以匯出 PDF 報告書!'); return; } const todayStr = new Date().toLocaleDateString('zh-TW'); const fullHtml = ` ${tripName} - 財務分帳結算報告書

✈️ ${tripName}

出遊財務結算與分帳對帳總報告 · 匯出日期:${todayStr} · 匯率基準:1 ${currentCurrency} = ${curRate.toFixed(4)} TWD
總支出 (折合台幣)
NT$ ${totalTwd.toLocaleString()}
${totalForeign > 0 ? ('
外幣支出總計 (' + currentCurrency + ')
' + ((CURRENCY_CONFIG[currentCurrency] && CURRENCY_CONFIG[currentCurrency].symbol) ? CURRENCY_CONFIG[currentCurrency].symbol : '¥') + ' ' + totalForeign.toLocaleString() + '
') : ''}
總消費筆數
${expenses.length} 筆
旅伴成員人數
${members.length} 人
👥 成員分帳結算淨額表
${membersSummaryHtml}
成員姓名 結算狀態 (應收/應付) 收款資訊 / LINE Pay
💳 最簡匯款清算指南 (誰該轉帳給誰)
🧾 完整消費流水明細與發票小票證明 (${expenses.length} 筆)
${expensesListHtml}
報告由 TripSplit 出遊雙幣資產管理系統自動生成 · 有圖有真相 · 帳目透明
`; printWindow.document.open(); printWindow.document.write(fullHtml); printWindow.document.close(); } function openCreateNewTripModal() { document.getElementById('newTripNameInput').value = ''; document.getElementById('newTripStartDateInput').value = getFormattedDate(0); document.getElementById('newTripEndDateInput').value = getFormattedDate(4); const hostInput = document.getElementById('newTripHostNameInput'); if (hostInput) hostInput.value = ''; openModal('createNewTripModal'); } function handleCreateNewTrip(e) { e.preventDefault(); const title = document.getElementById('newTripNameInput').value.trim(); const start = document.getElementById('newTripStartDateInput').value; const end = document.getElementById('newTripEndDateInput').value; const cur = document.getElementById('newTripCurrencySelect').value; const hostNameInput = document.getElementById('newTripHostNameInput'); const hostName = (hostNameInput && hostNameInput.value.trim()) ? hostNameInput.value.trim() : '我 (房主)'; if (!title || !start || !end) { alert('請完整填寫新旅程資訊!'); return; } const newTripId = 'trip_' + Date.now(); const newTripObj = { id: newTripId, title, startDate: start, endDate: end, currency: cur, rates: { JPY: 0.2019, KRW: 0.0238, USD: 31.85, EUR: 34.50, THB: 0.92 }, members: [ { id: 'm_owner_' + newTripId, name: hostName, isVirtual: false, note: '本次房主', bank: '' } ], expenses: [] }; trips.unshift(newTripObj); activeTripId = newTripObj.id; tripName = newTripObj.title; tripStartDate = newTripObj.startDate; tripEndDate = newTripObj.endDate; currentCurrency = newTripObj.currency; rates = newTripObj.rates; members = newTripObj.members; expenses = newTripObj.expenses; persistData(); closeModal('createNewTripModal'); navigateTo('trip'); } // 永久鎖定未來科技深色主題 (Cyber Dark Theme) function applyTheme() { document.documentElement.classList.add('dark'); if (window.lucide) lucide.createIcons(); } // 檢測當前環境:本地檔案 (file://) 或 本機伺服器 (localhost / 127.0.0.1) 為本機開發除錯模式 const isLocalEnv = window.location.protocol === 'file:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || window.location.hostname === '::1'; function initSupabaseClient() { // 本地開發模式 (本地 index.html 試錯、找 BUG、改欄位) ➔ 隔離資料庫,不同步雲端 if (isLocalEnv) { console.log('🛠️【本地開發模式】已啟動:資料庫與雲端同步已隔離,所有修改僅保存在本機,不會覆蓋或污染線上 SQL 資料庫!'); supabaseClient = null; updateCloudUI('offline', '🛠️ 本地除錯模式 (不同步SQL)'); return; } // 正式發布環境 (打包上傳至網路、GitHub Pages、Vercel 等線上網頁) ➔ 強制與 SQL 資料庫同步 if (SUPABASE_CONFIG.url && SUPABASE_CONFIG.anonKey && window.supabase) { try { supabaseClient = window.supabase.createClient(SUPABASE_CONFIG.url, SUPABASE_CONFIG.anonKey); updateCloudUI('connected', '🟢 已連線雲端'); console.log('🌐【線上生產環境】已連線:全面強制與 Supabase SQL 雲端資料庫保持即時同步!'); // 1. 立即從 Supabase 雲端資料庫抓取最新資料 syncFromSupabase(); syncPrivateFromSupabase(); syncBankBalancesFromSupabase(); syncAdminCredentialsFromSupabase(); // 2. 建立 Supabase Realtime 即時雙向監聽(當資料庫有變更時,網頁自動即時更新,不依賴本機快取!) supabaseClient.channel('realtime_all_sync') .on('postgres_changes', { event: '*', schema: 'public', table: 'expenses' }, () => { console.log('⚡ 偵測到雲端 expenses 變更,即時同步網頁!'); syncFromSupabase(); }) .on('postgres_changes', { event: '*', schema: 'public', table: 'members' }, () => { console.log('⚡ 偵測到雲端 members 變更,即時同步網頁!'); syncFromSupabase(); }) .on('postgres_changes', { event: '*', schema: 'public', table: 'trips' }, () => { console.log('⚡ 偵測到雲端 trips 變更,即時同步網頁!'); syncFromSupabase(); }) .on('postgres_changes', { event: '*', schema: 'public', table: 'private_expenses' }, () => { console.log('⚡ 偵測到雲端 private_expenses 變更,即時同步網頁!'); syncPrivateFromSupabase(); }) .on('postgres_changes', { event: '*', schema: 'public', table: 'bank_balances' }, () => { console.log('⚡ 偵測到雲端 bank_balances 變更,即時同步網頁!'); syncBankBalancesFromSupabase(); }) .subscribe(); } catch (e) { console.error('Supabase init error:', e); } } } // Pull cloud data from Supabase async function syncFromSupabase() { if (!supabaseClient) return; try { const { data: cloudTrips, error: tripsErr } = await supabaseClient.from('trips').select('*'); if (!tripsErr && cloudTrips && cloudTrips.length > 0) { const { data: cloudMembers } = await supabaseClient.from('members').select('*'); const { data: cloudExpenses } = await supabaseClient.from('expenses').select('*'); // Map cloud tables into trips structure trips = cloudTrips.map(ct => { const tMembers = (cloudMembers || []).filter(m => m.trip_id === ct.id).map(m => { const isH = (m.note && m.note.includes('房主')) || m.id === 'm1' || m.id.startsWith('m_owner_') || m.name.includes('房主'); return { id: m.id, name: m.name, isHost: isH, isVirtual: m.is_virtual, note: m.note || (isH ? '本次房主' : ''), bank: m.bank }; }).sort((a, b) => { const aIsOwner = a.isHost || a.id === 'm1' || a.id.startsWith('m_owner_') || a.name.includes('房主'); const bIsOwner = b.isHost || b.id === 'm1' || b.id.startsWith('m_owner_') || b.name.includes('房主'); return aIsOwner ? -1 : bIsOwner ? 1 : 0; }); const tExpenses = (cloudExpenses || []).filter(e => e.trip_id === ct.id).map(e => ({ id: e.id, day: e.day, title: e.title, originalAmount: parseFloat(e.original_amount), currency: e.currency, rate: parseFloat(e.rate), twdAmount: parseFloat(e.twd_amount), category: e.category, payerId: e.payer_id, splitWith: e.split_with || [], date: e.date })); const hasOwner = tMembers.some(m => m.id === 'm1' || m.id.startsWith('m_owner_') || (m.note && m.note.includes('房主')) || m.name.includes('房主')); if (!hasOwner && tMembers.length === 0) { const defaultOwnerId = ct.id === 'trip_1791549238247' ? 'm1' : ('m_owner_' + ct.id); tMembers.push({ id: defaultOwnerId, name: '我 (房主)', isVirtual: false, note: '本次房主', bank: '' }); } else if (!hasOwner && tMembers.length > 0) { // 若已有成員但未標記房主,將首位成員標記為房主 tMembers[0].isHost = true; tMembers[0].note = '本次房主'; } return { id: ct.id, title: ct.title, startDate: ct.start_date, endDate: ct.end_date, currency: ct.currency || 'JPY', rates: ct.rates || { JPY: 0.2019, KRW: 0.0238, USD: 31.85, EUR: 34.50, THB: 0.92 }, members: tMembers, expenses: tExpenses }; }); // Sync active trip let cur = trips.find(t => t.id === activeTripId) || trips[0]; activeTripId = cur.id; tripName = cur.title; tripStartDate = cur.startDate; tripEndDate = cur.endDate; currentCurrency = cur.currency; rates = cur.rates; members = cur.members; expenses = cur.expenses; localStorage.setItem('all_trips_registry', JSON.stringify(trips)); localStorage.setItem('active_trip_id', activeTripId); renderAll(); renderHubOverview(); } } catch (err) { console.log('雲端尚無資料表或尚未執行 SQL Editor 初始化,使用本地儲存'); } } // Push local trip changes to Supabase async function syncToSupabase() { if (!supabaseClient) return; try { // Upsert current trip await supabaseClient.from('trips').upsert({ id: activeTripId, title: tripName, start_date: tripStartDate, end_date: tripEndDate, currency: currentCurrency, rates: rates }); // Upsert members if (members && members.length > 0) { const membersToUpsert = members.map(m => { let finalMemberId = m.id; // 若為靜態 m1 且當前非初始固定旅程,賦予該旅程專屬 owner id,避免主鍵衝突 if (finalMemberId === 'm1' && activeTripId !== 'trip_1791549238247') { finalMemberId = 'm_owner_' + activeTripId; m.id = finalMemberId; } return { id: finalMemberId, trip_id: activeTripId, name: m.name, is_virtual: m.isVirtual, note: m.note || (m.isHost ? '本次房主' : ''), bank: m.bank || '' }; }); await supabaseClient.from('members').upsert(membersToUpsert); } // Upsert expenses if (expenses && expenses.length > 0) { const expensesToUpsert = expenses.map(e => ({ id: e.id, trip_id: activeTripId, day: (e.day !== undefined && e.day !== null) ? e.day : null, title: e.title, original_amount: e.originalAmount, currency: e.currency, rate: e.rate || (rates[e.currency] || 1), twd_amount: e.twdAmount, category: e.category, payer_id: e.payerId, split_with: e.splitWith, date: e.date })); const { error: expErr } = await supabaseClient.from('expenses').upsert(expensesToUpsert); if (expErr) { console.error('Supabase expenses upsert 失敗:', expErr.message); } else { console.log('✅ 出遊記帳已同步至 Supabase 雲端!'); } } } catch (err) { console.error('Supabase sync 異常:', err); } } function updateCloudUI(status, label) { const gearBadge = document.getElementById('gearBadgeDot'); const modalBadge = document.getElementById('modalCloudBadge'); const pingDot = document.getElementById('modalCloudPingDot'); const detailText = document.getElementById('modalCloudDetail'); const iconBox = document.getElementById('modalCloudIconBox'); if (gearBadge) { if (status === 'connected') { gearBadge.className = 'absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-zinc-800'; } else if (status === 'connecting') { gearBadge.className = 'absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-amber-400 animate-ping ring-2 ring-white dark:ring-zinc-800'; } else { gearBadge.className = 'absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-stone-400 ring-2 ring-white dark:ring-zinc-800'; } } if (modalBadge) { if (status === 'connected') { modalBadge.className = 'text-[10px] px-2.5 py-1 rounded-full bg-emerald-100 dark:bg-emerald-900/60 text-emerald-700 dark:text-emerald-300 font-bold border border-emerald-200 dark:border-emerald-800'; modalBadge.innerText = label || '🟢 已連線'; if (pingDot) pingDot.className = 'w-2 h-2 rounded-full bg-emerald-500 animate-pulse'; if (detailText) detailText.innerText = '連線正常 · 即時雙向同步中'; if (iconBox) iconBox.className = 'w-8 h-8 rounded-xl bg-emerald-100 dark:bg-emerald-950 flex items-center justify-center text-emerald-600 dark:text-emerald-400'; } else if (status === 'connecting') { modalBadge.className = 'text-[10px] px-2.5 py-1 rounded-full bg-amber-100 dark:bg-amber-900/60 text-amber-700 dark:text-amber-300 font-bold border border-amber-200 dark:border-amber-800'; modalBadge.innerText = '連線檢測中...'; if (pingDot) pingDot.className = 'w-2 h-2 rounded-full bg-amber-400 animate-ping'; if (detailText) detailText.innerText = '正在向 Supabase 伺服器發送心跳測試...'; if (iconBox) iconBox.className = 'w-8 h-8 rounded-xl bg-amber-100 dark:bg-amber-950 flex items-center justify-center text-amber-600 dark:text-amber-400'; } else { modalBadge.className = 'text-[10px] px-2.5 py-1 rounded-full bg-cyan-950/70 text-cyan-300 font-bold border border-cyan-500/40 shadow-[0_0_10px_rgba(0,240,255,0.2)] font-cyber'; modalBadge.innerText = label || '🛠️ 本地除錯模式 (不污染SQL)'; if (pingDot) pingDot.className = 'w-2 h-2 rounded-full bg-cyan-400'; if (detailText) detailText.innerText = '本地端試錯與開發中 · 已安全隔離線上 SQL 資料庫'; if (iconBox) iconBox.className = 'w-8 h-8 rounded-xl bg-cyan-950 border border-cyan-500/30 flex items-center justify-center text-cyan-400'; } } } // Calculate Trip Days and Current Day Number function calculateTripDays() { const start = new Date(tripStartDate); const end = new Date(tripEndDate); const diffTime = Math.max(0, end - start); const totalDays = Math.max(1, Math.round(diffTime / (1000 * 60 * 60 * 24)) + 1); // Current Day calculation based on today const today = new Date(); today.setHours(0, 0, 0, 0); const startZero = new Date(start); startZero.setHours(0, 0, 0, 0); const currentDiff = Math.floor((today - startZero) / (1000 * 60 * 60 * 24)) + 1; let currentDayText = 'Day 1'; if (currentDiff < 1) { currentDayText = '尚未出發'; } else if (currentDiff > totalDays) { currentDayText = '旅程結束'; } else { currentDayText = `Day ${currentDiff}`; } return { totalDays, currentDayText, currentDiff }; } function renderAll() { const { totalDays, currentDayText } = calculateTripDays(); const currentRate = rates[currentCurrency] || 0.2019; const curCfg = CURRENCY_CONFIG[currentCurrency] || CURRENCY_CONFIG.JPY; // Header display document.getElementById('tripTitleDisplay').innerText = tripName; document.getElementById('tripSubtitle').innerText = `群組分帳 · ${members.length} 人同行`; // Date box (依照示意圖:年份獨立一欄、月份/日期區間以超大字體顯示,永不縮減被截斷) const startParts = (tripStartDate || '').split('-'); const endParts = (tripEndDate || '').split('-'); const startYear = startParts[0] || '2026'; const endYear = endParts[0] || startYear; const yearText = (startYear === endYear) ? `${startYear} 年` : `${startYear} ~ ${endYear} 年`; const dateRangeText = (startParts.length >= 3 && endParts.length >= 3) ? `${startParts[1]}/${startParts[2]} ~ ${endParts[1]}/${endParts[2]}` : `${tripStartDate} ~ ${tripEndDate}`; const yearEl = document.getElementById('tripYearDisplay'); if (yearEl) yearEl.innerText = yearText; document.getElementById('tripDateDisplay').innerText = dateRangeText; document.getElementById('tripTotalDaysDisplay').innerText = `共 ${totalDays} 天行程`; document.getElementById('headerCurrentDayBadge').innerText = currentDayText; // Currency Box in Header document.getElementById('headerCurrencySelect').value = currentCurrency; document.getElementById('headerCurCodeText').innerText = currentCurrency; document.getElementById('headerRateDisplay').innerText = currentRate.toFixed(4); // Modal inputs document.getElementById('tripNameInput').value = tripName; document.getElementById('tripStartDateInput').value = tripStartDate; document.getElementById('tripEndDateInput').value = tripEndDate; // Form foreign currency label document.getElementById('formForeignCurLabel').innerText = `${curCfg.symbol} ${curCfg.name} (${currentCurrency})`; // Populate Member Selection for Stats Card const memberStatsSelect = document.getElementById('memberStatsSelect'); if (memberStatsSelect) { if (!members.some(m => m.id === selectedMemberForStats)) { selectedMemberForStats = members[0]?.id || 'm1'; } memberStatsSelect.innerHTML = members.map(m => ` `).join(''); } renderDaySelector(totalDays); renderExpenses(); renderMemberBadges(); renderMembersManagement(); renderSettlement(); lucide.createIcons(); } function changeStatsMember(memberId) { selectedMemberForStats = memberId; renderExpenses(); } // Save to LocalStorage (Synchronizes both active trip and all_trips_registry) function persistData() { // Sync back into trips array const idx = trips.findIndex(t => t.id === activeTripId); if (idx !== -1) { trips[idx].title = tripName; trips[idx].currency = currentCurrency; trips[idx].rates = rates; trips[idx].startDate = tripStartDate; trips[idx].endDate = tripEndDate; trips[idx].members = members; trips[idx].expenses = expenses; } localStorage.setItem('all_trips_registry', JSON.stringify(trips)); localStorage.setItem('active_trip_id', activeTripId); // Backward compatible keys localStorage.setItem('trip_title', tripName); localStorage.setItem('trip_currency', currentCurrency); localStorage.setItem('trip_rates', JSON.stringify(rates)); localStorage.setItem('trip_start_date', tripStartDate); localStorage.setItem('trip_end_date', tripEndDate); localStorage.setItem('trip_members', JSON.stringify(members)); localStorage.setItem('trip_expenses', JSON.stringify(expenses)); // Trigger cloud background sync to Supabase syncToSupabase(); } // Change Active Currency (Red box) function changeActiveCurrency(newCur) { if (CURRENCY_CONFIG[newCur]) { currentCurrency = newCur; persistData(); renderAll(); } } // Tab Switching function switchTab(tab) { document.getElementById('tabExpenses').classList.toggle('hidden', tab !== 'expenses'); document.getElementById('tabSettlement').classList.toggle('hidden', tab !== 'settlement'); document.getElementById('tabMembers').classList.toggle('hidden', tab !== 'members'); const navs = { expenses: document.getElementById('navExpenses'), settlement: document.getElementById('navSettlement'), }; Object.keys(navs).forEach(k => { if (!navs[k]) return; if (k === tab) { navs[k].className = 'flex-1 flex flex-col items-center gap-1 text-cyan-400 font-black font-scifi py-1 drop-shadow-[0_0_8px_rgba(0,240,255,0.6)] cursor-pointer'; } else { navs[k].className = 'flex-1 flex flex-col items-center gap-1 text-slate-400 hover:text-cyan-300 font-cyber py-1 transition cursor-pointer'; } }); if (tab === 'settlement') { renderSettlement(); } lucide.createIcons(); } // Render Travel Day Selector based on actual trip length function renderDaySelector(totalDays) { const modalContainer = document.getElementById('modalDaySelectorBar'); if (!modalContainer) return; const isAllSelected = currentSelectedDay.toString() === 'all'; const allActiveClass = isAllSelected ? 'bg-gradient-to-r from-cyan-500 to-blue-600 text-black font-black shadow-[0_0_10px_rgba(0,240,255,0.4)]' : 'bg-[#0d1630] border border-cyan-500/30 text-cyan-200 hover:border-cyan-400 hover:text-white font-bold'; const allBtnHtml = ` `; let daysHtml = ''; for (let i = 1; i <= totalDays; i++) { const isSelected = currentSelectedDay.toString() === i.toString(); const activeClass = isSelected ? 'bg-cyan-400 text-black font-black shadow-[0_0_10px_rgba(0,240,255,0.5)] border border-cyan-300' : 'bg-[#0d1630] border border-cyan-500/30 text-cyan-200 hover:border-cyan-400 hover:text-white font-bold'; daysHtml += ` `; } modalContainer.innerHTML = ` ${allBtnHtml}
${daysHtml}
`; // Populate inputExpenseDay select in form const daySelect = document.getElementById('inputExpenseDay'); if (daySelect) { daySelect.innerHTML = Array.from({ length: totalDays }, (_, i) => i + 1).map(d => ` `).join(''); } } function selectTravelDay(d) { currentSelectedDay = d; const modalSummaryEl = document.getElementById('modalDaySummaryText'); if (modalSummaryEl) { if (d === 'all') { modalSummaryEl.innerText = '檢視全行程 · 依天數與分類即時過濾'; } else { modalSummaryEl.innerText = `僅檢視 Day ${d} 消費明細 · 點擊其他天切換`; } } renderDaySelector(calculateTripDays().totalDays); renderExpenses(); if (window.lucide) { window.lucide.createIcons(); } } // Filter Categories function filterExpenses(category) { currentFilter = category; document.querySelectorAll('.modal-filter-btn').forEach(btn => { if (btn.dataset.filter === category) { btn.className = 'modal-filter-btn px-3 py-1 rounded-xl font-black font-cyber text-xs bg-gradient-to-r from-cyan-500 to-blue-600 text-black shadow-[0_0_10px_rgba(0,240,255,0.4)] whitespace-nowrap shrink-0 transition cursor-pointer active:scale-95'; } else { btn.className = 'modal-filter-btn px-3 py-1 rounded-xl font-bold font-cyber text-xs bg-[#0d1630] border border-cyan-500/35 text-slate-300 hover:border-cyan-400 hover:text-white whitespace-nowrap transition cursor-pointer active:scale-95'; } }); renderExpenses(); if (window.lucide) { window.lucide.createIcons(); } } // Currency Switcher in Expense Form function setFormCurrency(type) { formCurrencyType = type; const btnForeign = document.getElementById('currBtnForeign'); const btnTWD = document.getElementById('currBtnTWD'); const prefix = document.getElementById('formCurrencyPrefix'); const curCfg = CURRENCY_CONFIG[currentCurrency] || CURRENCY_CONFIG.JPY; if (type === 'FOREIGN') { btnForeign.className = 'px-2.5 py-1 rounded-md bg-white dark:bg-zinc-700 text-stone-900 dark:text-white shadow-xs transition'; btnTWD.className = 'px-2.5 py-1 rounded-md text-stone-500 dark:text-zinc-400 transition'; prefix.innerText = curCfg.symbol; } else { btnTWD.className = 'px-2.5 py-1 rounded-md bg-white dark:bg-zinc-700 text-stone-900 dark:text-white shadow-xs transition'; btnForeign.className = 'px-2.5 py-1 rounded-md text-stone-500 dark:text-zinc-400 transition'; prefix.innerText = 'NT$'; } updateConvertedPreview(); } // Calculator & Evaluator function evaluateExpression(expr) { if (!expr) return 0; try { const cleaned = expr.toString().replace(/[^0-9+\-*/.]/g, ''); if (!cleaned) return 0; // eslint-disable-next-line no-eval const result = Function('"use strict";return (' + cleaned + ')')(); return isNaN(result) ? 0 : Math.max(0, result); } catch (e) { return 0; } } function updateConvertedPreview() { const rawVal = document.getElementById('inputExpenseAmount').value; const evaluated = evaluateExpression(rawVal); const previewEl = document.getElementById('conversionPreviewText'); const rateText = document.getElementById('formCurrentRateText'); const curRate = rates[currentCurrency] || 0.2019; const curCfg = CURRENCY_CONFIG[currentCurrency] || CURRENCY_CONFIG.JPY; rateText.innerText = `${currentCurrency} 匯率 ${curRate.toFixed(4)}`; if (formCurrencyType === 'FOREIGN') { const twd = Math.round(evaluated * curRate); previewEl.innerText = `折合台幣:約 NT$ ${twd.toLocaleString()}`; previewEl.classList.remove('hidden'); } else { previewEl.innerText = ''; previewEl.classList.add('hidden'); } } // Category Selector function selectCategory(cat) { currentCategory = cat; document.querySelectorAll('#categoryChips button').forEach(btn => { if (btn.dataset.cat === cat) { btn.className = 'cat-btn px-2.5 py-1 text-xs rounded-full border border-cyan-400 bg-gradient-to-r from-cyan-500 to-blue-600 text-black font-black font-cyber shadow-[0_0_10px_rgba(0,240,255,0.4)] text-center transition'; } else { btn.className = 'cat-btn px-2.5 py-1 text-xs rounded-full border border-cyan-500/30 bg-[#0d1630] text-cyan-200 hover:border-cyan-400 font-cyber transition'; } }); const personalTip = document.getElementById('personalExpenseTip'); const dayContainer = document.getElementById('inputExpenseDayContainer'); const titleContainer = document.getElementById('inputExpenseTitleContainer'); // 機票與保險預設為個人花費 const isPersonal = (cat === 'flight' || cat === 'insurance'); // 機票、保險、住宿、租車(自駕) 都不綁定特定天數 (不設為第N天,隱藏天數選單,直接計入行程總花費) const isWholeTripCategory = (cat === 'flight' || cat === 'insurance' || cat === 'stay' || cat === 'car_rental'); if (personalTip) { personalTip.classList.toggle('hidden', !isPersonal); } if (dayContainer && titleContainer) { if (isWholeTripCategory) { dayContainer.classList.add('hidden'); titleContainer.className = 'col-span-3'; } else { dayContainer.classList.remove('hidden'); titleContainer.className = 'col-span-2'; } } // 若選擇機票或保險,預設勾選僅付款人自己負擔 if (isPersonal) { setPersonalOnlySplitter(); } else { // 若切換為住宿或租車或個人花費,若之前全選被改掉可由使用者自行勾選,住宿與租車預設為全員分攤 if (cat === 'stay' || cat === 'car_rental') { selectAllSplitters(true); } } } // Set splitters to ONLY the selected payer function setPersonalOnlySplitter() { const payerId = document.getElementById('selectPayer')?.value; if (!payerId) return; document.querySelectorAll('input[name="splitMember"]').forEach(cb => { cb.checked = (cb.value === payerId); }); } // When payer changes in dropdown, if current item is personal expense, update the checked box function handlePayerChange() { if (currentCategory === 'flight' || currentCategory === 'insurance') { setPersonalOnlySplitter(); } } // Render Member Badges and Button on Header function renderMemberBadges() { // 更新主畫面成員數量徽章 const badgeEl = document.getElementById('memberCountBadge'); if (badgeEl) { badgeEl.innerText = `${members.length} 人`; } // 同步更新彈跳視窗內的成員列表與筆數 renderTripMembersModal(); } // 開啟同行成員彈跳視窗 function openTripMembersModal() { renderTripMembersModal(); openModal('tripMembersModal'); const modalEl = document.getElementById('tripMembersModal'); if (modalEl) { modalEl.scrollTop = 0; const inner = modalEl.firstElementChild; if (inner) inner.scrollTop = 0; } if (window.lucide) { window.lucide.createIcons(); } } // 渲染同行成員彈跳視窗內容 function renderTripMembersModal() { const modalCountEl = document.getElementById('modalTripMemberCountBadge'); if (modalCountEl) { modalCountEl.innerText = `${members.length} 人`; } const listContainer = document.getElementById('modalMembersListContainer'); if (!listContainer) return; listContainer.innerHTML = members.map(m => { const isHost = isMemberHost(m); const cleanName = getMemberCleanName(m.name); let avatarBg = 'bg-cyan-950 border border-cyan-400/50 text-cyan-300'; let badgeClass = 'bg-cyan-950/80 text-cyan-300 border border-cyan-500/40'; let roleText = '一般旅伴 (同行)'; if (isHost) { avatarBg = 'bg-amber-950 border border-amber-400/60 text-amber-300 shadow-[0_0_10px_rgba(251,191,36,0.3)]'; badgeClass = 'bg-amber-950/80 text-amber-300 border border-amber-400/50 shadow-[0_0_6px_rgba(251,191,36,0.3)] font-black'; roleText = '👑 房主 (HOST)'; } else if (m.isVirtual) { avatarBg = 'bg-purple-950 border border-purple-400/50 text-purple-300'; badgeClass = 'bg-purple-950/80 text-purple-300 border border-purple-500/40'; roleText = '🤖 虛擬成員 (代記代墊)'; } return `
${cleanName.slice(0, 1)}
${cleanName} ${roleText}

${m.bank || '無指定收款帳號'}

`; }).join(''); } // Render Expenses List function renderExpenses() { const container = document.getElementById('expenseList'); const totalAmountEl = document.getElementById('totalExpenseAmount'); const totalForeignEl = document.getElementById('totalForeignAmount'); const countEl = document.getElementById('expenseCountText'); const myShareEl = document.getElementById('myShareAmount'); const myShareForeignEl = document.getElementById('myShareForeignAmount'); const statsTextEl = document.getElementById('filteredStatsText'); const curRate = rates[currentCurrency] || 0.2019; const curCfg = CURRENCY_CONFIG[currentCurrency] || CURRENCY_CONFIG.JPY; let totalTwd = 0; let sharedCount = 0; let cardSumTwd = 0; let cardSumForeign = 0; // 當前選擇旅伴之個人總花費 (分攤共同支出應付額 + 個人自付花費) let memberPersonalTwd = 0; let memberPersonalForeign = 0; let targetMemberId = selectedMemberForStats || (members[0]?.id || 'm1'); // Calculate totals: 統計共同花費與選定成員個人總花費 expenses.forEach(item => { const itemRate = item.currency === 'TWD' ? 1 : (item.rate || curRate); const itemTwd = item.currency === 'TWD' ? item.originalAmount : Math.round(item.originalAmount * itemRate); // 個人花費判斷:分攤人數僅 1 人且為付款人自己 (自付不平分,例如個人機票或個人獨購) const isPersonal = item.splitWith.length === 1 && item.splitWith[0] === item.payerId; // 1. 共同總支出統計 (排除各人自付私費) if (!isPersonal) { totalTwd += itemTwd; sharedCount++; if (item.currency === 'TWD') { cardSumTwd += item.originalAmount; } else { cardSumForeign += item.originalAmount; } } // 2. 選定旅伴 (targetMemberId) 之個人總花費計算: if (isPersonal) { // 若為個人不用分擔之花費,且該花費屬於此旅伴(payerId === targetMemberId 或 splitWith 包含該人) if (item.payerId === targetMemberId || (item.splitWith && item.splitWith.includes(targetMemberId))) { if (item.currency === 'TWD') { memberPersonalTwd += item.originalAmount; } else { memberPersonalForeign += item.originalAmount; } } } else { // 若為共同分攤花費,且該旅伴在分攤名單內,計入其應負擔份額 (按人數平分) if (item.splitWith && item.splitWith.includes(targetMemberId)) { const splitCount = item.splitWith.length || 1; const myShareAmt = item.originalAmount / splitCount; if (item.currency === 'TWD') { memberPersonalTwd += Math.round(myShareAmt); } else { memberPersonalForeign += Math.round(myShareAmt); } } } }); // 1. 共同總支出換算 const cardForeignEquivTwd = Math.round(cardSumForeign * curRate); const cardGrandTotalTwd = cardSumTwd + cardForeignEquivTwd; // 2. 該行程個人總花費換算 (所選旅伴之分攤份額 + 個人自付花費) const memberForeignEquivTwd = Math.round(memberPersonalForeign * curRate); const memberGrandTotalTwd = memberPersonalTwd + memberForeignEquivTwd; // 更新共同總支出 3 行卡片 const cardTwdEl = document.getElementById('cardTwdSumText'); const cardForeignEl = document.getElementById('cardForeignSumText'); const cardForeignToTwdEl = document.getElementById('cardForeignToTwdText'); if (cardTwdEl) cardTwdEl.innerText = `NT$ ${cardSumTwd.toLocaleString()}`; if (cardForeignEl) cardForeignEl.innerText = `${curCfg.symbol} ${cardSumForeign.toLocaleString()} ${currentCurrency}`; if (cardForeignToTwdEl) cardForeignToTwdEl.innerText = `(等值約 NT$ ${cardForeignEquivTwd.toLocaleString()})`; if (totalAmountEl) totalAmountEl.innerText = `NT$ ${cardGrandTotalTwd.toLocaleString()}`; if (totalForeignEl) totalForeignEl.innerText = `約 ${curCfg.symbol} ${(Math.round(cardGrandTotalTwd / curRate)).toLocaleString()} ${currentCurrency}`; if (countEl) countEl.innerText = `${sharedCount} 筆公費`; // 更新「該行程個人總花費」3 行卡片 const allTwdEl = document.getElementById('tripAllTwdSumText'); const allForeignEl = document.getElementById('tripAllForeignSumText'); const allForeignToTwdEl = document.getElementById('tripAllForeignToTwdText'); const allGrandTotalEl = document.getElementById('tripAllGrandTotalText'); const allCountEl = document.getElementById('allExpenseCountText'); if (allTwdEl) allTwdEl.innerText = `NT$ ${memberPersonalTwd.toLocaleString()}`; if (allForeignEl) allForeignEl.innerText = `${curCfg.symbol} ${memberPersonalForeign.toLocaleString()} ${currentCurrency}`; if (allForeignToTwdEl) allForeignToTwdEl.innerText = `(等值約 NT$ ${memberForeignEquivTwd.toLocaleString()})`; if (allGrandTotalEl) allGrandTotalEl.innerText = `NT$ ${memberGrandTotalTwd.toLocaleString()}`; if (allCountEl) allCountEl.innerText = `${expenses.length} 筆明細`; if (myShareEl) myShareEl.innerText = `NT$ ${memberGrandTotalTwd.toLocaleString()}`; if (myShareForeignEl) myShareForeignEl.innerText = `約 ${curCfg.symbol} ${(Math.round(memberGrandTotalTwd / curRate)).toLocaleString()} ${currentCurrency}`; // Filter by category and Day let filtered = expenses; if (currentFilter !== 'all') { filtered = filtered.filter(e => e.category === currentFilter); } if (currentSelectedDay !== 'all') { // 機票、保險、住宿、租車(自駕) 不綁定特定第 N 天,切換到特定天數時不出現;但在「全部天數」會完整顯示並計入總額 filtered = filtered.filter(e => { if (e.category === 'flight' || e.category === 'insurance' || e.category === 'stay' || e.category === 'car_rental' || e.day === null) { return false; } return (e.day || 1).toString() === currentSelectedDay.toString(); }); } // Update badges and summaries const mainTotalBadge = document.getElementById('tripExpenseTotalBadge'); if (mainTotalBadge) { mainTotalBadge.innerText = `${expenses.length} 筆`; } const modalCountBadge = document.getElementById('modalTripExpenseCountBadge'); if (modalCountBadge) { modalCountBadge.innerText = `${filtered.length} 筆`; } if (statsTextEl) { statsTextEl.innerText = `顯示 (${filtered.length} 筆)`; } // Filtered total in display currency const modalFilterSummaryEl = document.getElementById('modalTripExpenseFilterSummary'); if (modalFilterSummaryEl) { let filterSumTwd = 0; let filterSumForeign = 0; filtered.forEach(it => { if (it.currency === 'TWD') { filterSumTwd += it.originalAmount; } else { filterSumForeign += it.originalAmount; } }); const filterTotalEquivTwd = filterSumTwd + Math.round(filterSumForeign * curRate); modalFilterSummaryEl.innerText = `NT$ ${filterTotalEquivTwd.toLocaleString()}`; } const modalListContainer = document.getElementById('modalTripExpenseList'); if (filtered.length === 0) { const emptyHtml = `
目前條件下尚無消費明細,點擊右上角「記一筆」新增!
`; if (container) container.innerHTML = emptyHtml; if (modalListContainer) modalListContainer.innerHTML = emptyHtml; return; } const iconMap = { food: '⚡', shopping: '🛍️', traffic: '🚀', stay: '🪐', car_rental: '🏎️', play: '🎟️', flight: '🛸', insurance: '🛡️' }; const itemsHtml = filtered.map(item => { const itemRate = item.currency === 'TWD' ? 1 : (item.rate || curRate); const itemTwd = item.currency === 'TWD' ? item.originalAmount : Math.round(item.originalAmount * itemRate); const payer = members.find(m => m.id === item.payerId) || { name: '未知' }; const itemSymbol = item.currency === 'TWD' ? 'NT$' : (CURRENCY_CONFIG[item.currency]?.symbol || '¥'); const displayOriginal = `${itemSymbol} ${item.originalAmount.toLocaleString()} ${item.currency}`; const subDisplay = item.currency === 'TWD' ? '' : `約 NT$ ${itemTwd.toLocaleString()}`; // 標籤顯示:精緻輕巧 let dayBadgeClass = 'bg-cyan-950/80 text-cyan-300 border border-cyan-500/40 shadow-xs'; let dayLabel = item.day ? `Day ${item.day}` : 'Day 1'; if (item.category === 'flight') { dayLabel = '🛸 機票'; dayBadgeClass = 'bg-sky-950/80 text-sky-300 border border-sky-400/50 shadow-xs'; } else if (item.category === 'insurance') { dayLabel = '🛡️ 保險'; dayBadgeClass = 'bg-emerald-950/80 text-emerald-300 border border-emerald-400/50 shadow-xs'; } else if (item.category === 'stay') { dayLabel = '🪐 住宿'; dayBadgeClass = 'bg-amber-950/80 text-amber-300 border border-amber-400/50 shadow-xs'; } else if (item.category === 'car_rental') { dayLabel = '🏎️ 租車(自駕)'; dayBadgeClass = 'bg-indigo-950/80 text-indigo-300 border border-indigo-400/50 shadow-xs'; } else if (item.day === null) { dayLabel = '全域總覽'; } const isPersonal = item.splitWith.length === 1 && item.splitWith[0] === item.payerId; const splitText = isPersonal ? `[個人自付]` : `平均分攤 (${item.splitWith.length}人)`; return `
${iconMap[item.category] || '⚡'}
${dayLabel}

${item.title} ${item.receiptImg ? `` : ''}

${getMemberCleanName(payer.name)} 付 ·
${splitText}
${displayOriginal}
${subDisplay ? `
${subDisplay}
` : ''}
|
`; }).join(''); if (container) container.innerHTML = itemsHtml; if (modalListContainer) modalListContainer.innerHTML = itemsHtml; } // 開啟旅程消費明細彈跳視窗 function openTripExpenseDetailModal() { renderDaySelector(calculateTripDays().totalDays); renderExpenses(); openModal('tripExpenseDetailModal'); const modalEl = document.getElementById('tripExpenseDetailModal'); if (modalEl) { modalEl.scrollTop = 0; const inner = modalEl.firstElementChild; if (inner) inner.scrollTop = 0; } if (window.lucide) { window.lucide.createIcons(); } } // Batch Individual Expense Mode State & Logic let isBatchIndividualMode = false; function toggleBatchIndividualMode() { const cb = document.getElementById('batchIndividualCheckbox'); if (cb) { cb.checked = !cb.checked; handleBatchIndividualToggle(cb.checked); } } function handleBatchIndividualToggle(active) { isBatchIndividualMode = active; const cb = document.getElementById('batchIndividualCheckbox'); if (cb) cb.checked = active; const badge = document.getElementById('batchIndividualBadge'); const notice = document.getElementById('batchIndividualActiveNotice'); const payerWrapper = document.getElementById('payerSectionWrapper'); const payerNotice = document.getElementById('batchPayerNotice'); const splitterLabel = document.getElementById('splitterLabelText'); const submitBtn = document.getElementById('expenseSubmitBtn'); if (active) { if (badge) { badge.innerText = '已啟動 ON'; badge.className = 'text-[9px] px-1.5 py-0.2 rounded font-scifi bg-cyan-500 text-black font-black shadow-[0_0_8px_rgba(0,240,255,0.4)]'; } if (notice) notice.classList.remove('hidden'); if (payerWrapper) { const selectPayer = document.getElementById('selectPayer'); if (selectPayer) selectPayer.classList.add('hidden'); const payerLabel = document.getElementById('payerLabelText'); if (payerLabel) payerLabel.innerText = '付款模式:每人各自自付'; } if (payerNotice) payerNotice.classList.remove('hidden'); if (splitterLabel) splitterLabel.innerText = '各自購買此項目的成員名單 (將為每人各建立一筆支出)'; updateBatchSubmitButtonText(); } else { if (badge) { badge.innerText = '關閉 OFF'; badge.className = 'text-[9px] px-1.5 py-0.2 rounded font-scifi bg-slate-800 text-slate-400 border border-slate-700'; } if (notice) notice.classList.add('hidden'); if (payerWrapper) { const selectPayer = document.getElementById('selectPayer'); if (selectPayer) selectPayer.classList.remove('hidden'); const payerLabel = document.getElementById('payerLabelText'); if (payerLabel) payerLabel.innerText = '付款人 *'; } if (payerNotice) payerNotice.classList.add('hidden'); if (splitterLabel) splitterLabel.innerText = '分攤人員 (費用平分對象)'; if (submitBtn) { const editingId = document.getElementById('editingExpenseId').value; submitBtn.innerText = editingId ? '更新明細' : '儲存記帳'; } } lucide.createIcons(); } function updateBatchSubmitButtonText() { if (!isBatchIndividualMode) return; const submitBtn = document.getElementById('expenseSubmitBtn'); const checkedBoxes = Array.from(document.querySelectorAll('input[name="splitMember"]:checked')); const count = checkedBoxes.length; if (submitBtn) { submitBtn.innerText = `一鍵建立 ${count} 筆個人自付消費`; } } function setPersonalOnlySplitter() { const payerId = document.getElementById('selectPayer').value; document.querySelectorAll('input[name="splitMember"]').forEach(cb => { cb.checked = (cb.value === payerId); }); if (isBatchIndividualMode) updateBatchSubmitButtonText(); } function handlePayerChange() { // If needed } // Modal Control function openModal(id) { const el = document.getElementById(id); if (el) { el.classList.remove('hidden'); el.scrollTop = 0; const inner = el.querySelector('.cyber-glass') || el.firstElementChild; if (inner && inner.scrollTop) inner.scrollTop = 0; } window.scrollTo(0, 0); } function closeModal(id) { document.getElementById(id).classList.add('hidden'); } // Open Add Expense Modal function openAddExpenseModal() { document.getElementById('editingExpenseId').value = ''; document.getElementById('expenseModalTitle').innerText = '新增消費明細'; document.getElementById('expenseSubmitBtn').innerText = '儲存記帳'; // Show and reset batch mode const batchContainer = document.getElementById('batchIndividualModeContainer'); if (batchContainer) batchContainer.classList.remove('hidden'); handleBatchIndividualToggle(false); const payerSelect = document.getElementById('selectPayer'); payerSelect.innerHTML = members.map(m => ` `).join(''); const splittersDiv = document.getElementById('splitterCheckboxes'); splittersDiv.innerHTML = members.map(m => ` `).join(''); document.getElementById('inputExpenseTitle').value = ''; document.getElementById('inputExpenseAmount').value = ''; const { totalDays, currentDiff } = calculateTripDays(); let defaultDay = 1; if (currentSelectedDay !== 'all') { defaultDay = currentSelectedDay; } else if (currentDiff >= 1 && currentDiff <= totalDays) { defaultDay = currentDiff; } document.getElementById('inputExpenseDay').value = defaultDay; setFormCurrency('FOREIGN'); selectCategory('food'); updateConvertedPreview(); openModal('addExpenseModal'); } // Edit Existing Expense function editExpense(id) { const item = expenses.find(e => e.id === id); if (!item) return; document.getElementById('editingExpenseId').value = item.id; document.getElementById('expenseModalTitle').innerText = '編輯消費明細'; document.getElementById('expenseSubmitBtn').innerText = '更新明細'; // Hide batch container in edit mode const batchContainer = document.getElementById('batchIndividualModeContainer'); if (batchContainer) batchContainer.classList.add('hidden'); handleBatchIndividualToggle(false); const payerSelect = document.getElementById('selectPayer'); payerSelect.innerHTML = members.map(m => ` `).join(''); const splittersDiv = document.getElementById('splitterCheckboxes'); splittersDiv.innerHTML = members.map(m => ` `).join(''); document.getElementById('inputExpenseTitle').value = item.title; document.getElementById('inputExpenseAmount').value = item.originalAmount; document.getElementById('inputExpenseDay').value = item.day || 1; setFormCurrency(item.currency === 'TWD' ? 'TWD' : 'FOREIGN'); // Update category highlight without overriding loaded checkboxes currentCategory = item.category || 'food'; document.querySelectorAll('#categoryChips button').forEach(btn => { if (btn.dataset.cat === currentCategory) { btn.className = 'cat-btn px-2.5 py-1 text-xs rounded-full border border-stone-800 dark:border-zinc-300 bg-stone-100 dark:bg-zinc-800 font-bold text-center transition'; } else { btn.className = 'cat-btn px-2.5 py-1 text-xs rounded-full border border-stone-200 dark:border-zinc-700 bg-white dark:bg-zinc-800/40 text-stone-600 dark:text-zinc-400 transition'; } }); const personalTip = document.getElementById('personalExpenseTip'); const dayContainer = document.getElementById('inputExpenseDayContainer'); const titleContainer = document.getElementById('inputExpenseTitleContainer'); const isWholeTripCategory = (currentCategory === 'flight' || currentCategory === 'insurance' || currentCategory === 'stay' || currentCategory === 'car_rental'); if (personalTip) { personalTip.classList.toggle('hidden', !(currentCategory === 'flight' || currentCategory === 'insurance')); } if (dayContainer && titleContainer) { if (isWholeTripCategory) { dayContainer.classList.add('hidden'); titleContainer.className = 'col-span-3'; } else { dayContainer.classList.remove('hidden'); titleContainer.className = 'col-span-2'; } } updateConvertedPreview(); openModal('addExpenseModal'); } function selectAllSplitters(checked) { document.querySelectorAll('input[name="splitMember"]').forEach(cb => cb.checked = checked); if (isBatchIndividualMode) updateBatchSubmitButtonText(); } // Save or Update Expense function handleSaveExpense(e) { e.preventDefault(); const editingId = document.getElementById('editingExpenseId').value; const title = document.getElementById('inputExpenseTitle').value.trim(); const rawExpr = document.getElementById('inputExpenseAmount').value; const dayVal = parseInt(document.getElementById('inputExpenseDay').value) || 1; const rawAmount = evaluateExpression(rawExpr); const payerId = document.getElementById('selectPayer').value; if (!rawAmount || rawAmount <= 0) { alert('請輸入有效的金額!'); return; } const checkedBoxes = Array.from(document.querySelectorAll('input[name="splitMember"]:checked')); const splitWith = checkedBoxes.map(cb => cb.value); if (splitWith.length === 0) { alert('請至少勾選一位成員!'); return; } const curRate = rates[currentCurrency] || 0.2019; const isTwd = formCurrencyType === 'TWD'; const expenseCur = isTwd ? 'TWD' : currentCurrency; const twd = isTwd ? Math.round(rawAmount) : Math.round(rawAmount * curRate); const isSpecialGeneral = (currentCategory === 'flight' || currentCategory === 'insurance' || currentCategory === 'stay' || currentCategory === 'car_rental'); const finalDay = isSpecialGeneral ? null : dayVal; if (editingId) { const idx = expenses.findIndex(e => e.id === editingId); if (idx !== -1) { expenses[idx] = { ...expenses[idx], title, day: finalDay, originalAmount: rawAmount, currency: expenseCur, rate: isTwd ? 1 : curRate, twdAmount: twd, category: currentCategory, payerId, splitWith }; } } else if (isBatchIndividualMode) { // 每人各自付等額模式:為每位勾選成員各自建立一筆個人自付消費 const nowBase = Date.now(); splitWith.forEach((memId, index) => { const personalExpense = { id: 'e_' + (nowBase + index), day: finalDay, title: title, originalAmount: rawAmount, currency: expenseCur, rate: isTwd ? 1 : curRate, twdAmount: twd, category: currentCategory, payerId: memId, splitWith: [memId], date: new Date().toLocaleString() }; expenses.unshift(personalExpense); }); } else { const newExpense = { id: 'e_' + Date.now(), day: finalDay, title, originalAmount: rawAmount, currency: expenseCur, rate: isTwd ? 1 : curRate, twdAmount: twd, category: currentCategory, payerId, splitWith, date: new Date().toLocaleString() }; expenses.unshift(newExpense); } persistData(); renderAll(); closeModal('addExpenseModal'); } function deleteExpense(id) { if (confirm('確定刪除此筆記帳嗎?')) { expenses = expenses.filter(e => e.id !== id); persistData(); if (supabaseClient) { supabaseClient.from('expenses').delete().eq('id', id).then(() => { console.log('✅ 雲端 expenses 已刪除項目:', id); }).catch(err => console.warn('雲端刪除失敗:', err)); } renderAll(); } } // Member Form handling function openAddMemberModal() { document.getElementById('editingMemberId').value = ''; document.getElementById('memberModalTitle').innerText = '新增旅伴成員'; document.getElementById('memberSubmitBtn').innerText = '確定新增'; document.getElementById('newMemberName').value = ''; document.getElementById('newMemberType').value = 'virtual'; document.getElementById('newMemberAccount').value = ''; openModal('addMemberModal'); } function editMember(id) { const m = members.find(item => item.id === id); if (!m) return; const isHost = isMemberHost(m); const cleanName = getMemberCleanName(m.name); document.getElementById('editingMemberId').value = m.id; document.getElementById('memberModalTitle').innerText = isHost ? `修改房主資料 - ${cleanName}` : `修改成員資料 - ${cleanName}`; document.getElementById('memberSubmitBtn').innerText = '儲存修改'; document.getElementById('newMemberName').value = cleanName; const typeSelect = document.getElementById('newMemberType'); if (typeSelect) { if (isHost) { typeSelect.value = 'host'; } else if (m.isVirtual) { typeSelect.value = 'virtual'; } else { typeSelect.value = 'real'; } } document.getElementById('newMemberAccount').value = m.bank || ''; openModal('addMemberModal'); } function handleSaveMember(e) { e.preventDefault(); const editingId = document.getElementById('editingMemberId').value; const rawName = document.getElementById('newMemberName').value.trim(); const name = getMemberCleanName(rawName); const memberType = document.getElementById('newMemberType').value; const bank = document.getElementById('newMemberAccount').value.trim(); if (!name) return; const isHost = (memberType === 'host'); const isVirtual = (memberType === 'virtual'); const note = isHost ? '本次房主' : (isVirtual ? '虛擬代記' : '一般旅伴'); // 允許多位房主,不限制房主數量 (不再清除其他成員的房主身份) if (editingId) { const idx = members.findIndex(m => m.id === editingId); if (idx !== -1) { members[idx].name = name; members[idx].isHost = isHost; members[idx].isVirtual = isVirtual; members[idx].note = note; members[idx].bank = bank || '未設定收款帳號'; } } else { const newMid = isHost ? ('m_owner_' + Date.now() + '_' + Math.random().toString(36).substring(2, 6)) : ('m_' + Date.now()); members.push({ id: newMid, name, isHost, isVirtual, note, bank: bank || '未設定收款帳號' }); } persistData(); renderAll(); closeModal('addMemberModal'); document.getElementById('addMemberForm').reset(); } function setAccountPreset(preset) { const input = document.getElementById('newMemberAccount'); input.value = preset; input.focus(); } function deleteMember(id) { if (members.length <= 1) { alert('群組至少需要保留 1 位成員!'); return; } const target = members.find(m => m.id === id); const isHost = target && (target.isHost || target.id === 'm1' || target.id.startsWith('m_owner_') || (target.note && target.note.includes('房主'))); // 如果要刪除的是房主,且還有其他成員,提醒 if (isHost) { const remainingHosts = members.filter(m => m.id !== id && (m.isHost || m.id === 'm1' || m.id.startsWith('m_owner_') || (m.note && m.note.includes('房主')))); const hostWarning = remainingHosts.length > 0 ? `「${target.name}」為本次旅程的房主之一,確定要移除嗎?` : `「${target.name}」為本次旅程唯一的房主,確定要移除嗎?\n移除後系統將自動由下一位成員接續擔任房主。`; if (!confirm(hostWarning)) { return; } } else { if (!confirm(`確定刪除成員「${target ? target.name : ''}」嗎?其已參與的歷史記帳將一併受影響。`)) { return; } } members = members.filter(m => m.id !== id); // 若群組內已無任何房主,自動將第 1 位成員設為房主 const hasHost = members.some(m => m.isHost || m.id === 'm1' || m.id.startsWith('m_owner_') || (m.note && m.note.includes('房主'))); if (!hasHost && members.length > 0) { members[0].isHost = true; members[0].note = '本次房主'; } persistData(); if (supabaseClient) { supabaseClient.from('members').delete().eq('id', id).then(() => { console.log('✅ 雲端 members 已刪除:', id); }).catch(err => console.warn('雲端刪除成員失敗:', err)); } renderAll(); } // Render Members Management Tab function renderMembersManagement() { const list = document.getElementById('membersManagementList'); list.innerHTML = members.map(m => { const isHost = isMemberHost(m); const cleanName = getMemberCleanName(m.name); let avatarBg = 'bg-cyan-950 border border-cyan-400/50 text-cyan-300'; let badgeClass = 'bg-cyan-950/80 text-cyan-300 border border-cyan-500/40'; let roleText = '一般旅伴 (同行)'; if (isHost) { avatarBg = 'bg-amber-950 border border-amber-400/60 text-amber-300 shadow-[0_0_10px_rgba(251,191,36,0.3)]'; badgeClass = 'bg-amber-950/80 text-amber-300 border border-amber-400/50 shadow-[0_0_6px_rgba(251,191,36,0.3)] font-black'; roleText = '👑 本次房主 (HOST)'; } else if (m.isVirtual) { avatarBg = 'bg-purple-950 border border-purple-400/50 text-purple-300'; badgeClass = 'bg-purple-950/80 text-purple-300 border border-purple-500/40'; roleText = '🤖 虛擬成員 (代記代墊)'; } return `
${cleanName.slice(0, 1)}
${cleanName} ${roleText}

${m.bank || '無指定收款帳號'}

`; }).join(''); } // ================= 智慧分帳結算核心 (支援台幣與外幣獨立結算,並提供匯率換算選項) ================= function calculateSettlement() { const curRate = rates[currentCurrency] || 0.2019; const curCfg = CURRENCY_CONFIG[currentCurrency] || CURRENCY_CONFIG.JPY; // 獨立追蹤每人的外幣淨額 (如 JPY) 與台幣淨額 (TWD) const foreignBalanceMap = {}; const twdBalanceMap = {}; members.forEach(m => { foreignBalanceMap[m.id] = 0; twdBalanceMap[m.id] = 0; }); // 累計各款項 expenses.forEach(item => { const splitCount = item.splitWith.length; if (splitCount === 0) return; // 個人私費不計入共同分攤 const isPersonal = splitCount === 1 && item.splitWith[0] === item.payerId; if (isPersonal) return; const isTwd = (item.currency === 'TWD'); const amt = item.originalAmount; const perPerson = amt / splitCount; if (isTwd) { if (twdBalanceMap[item.payerId] !== undefined) twdBalanceMap[item.payerId] += amt; item.splitWith.forEach(mid => { if (twdBalanceMap[mid] !== undefined) twdBalanceMap[mid] -= perPerson; }); } else { if (foreignBalanceMap[item.payerId] !== undefined) foreignBalanceMap[item.payerId] += amt; item.splitWith.forEach(mid => { if (foreignBalanceMap[mid] !== undefined) foreignBalanceMap[mid] -= perPerson; }); } }); // 計算最小轉帳路徑輔助函式 function solveTransfers(balMap) { const debtors = []; const creditors = []; Object.entries(balMap).forEach(([id, bal]) => { const rounded = Math.round(bal); if (rounded < -1) { debtors.push({ id, amount: Math.abs(rounded) }); } else if (rounded > 1) { creditors.push({ id, amount: rounded }); } }); const transfers = []; let i = 0; let j = 0; while (i < debtors.length && j < creditors.length) { const d = debtors[i]; const c = creditors[j]; const transAmt = Math.min(d.amount, c.amount); transfers.push({ from: d.id, to: c.id, amount: transAmt }); d.amount -= transAmt; c.amount -= transAmt; if (d.amount === 0) i++; if (c.amount === 0) j++; } return transfers; } const foreignTransfers = solveTransfers(foreignBalanceMap); const twdTransfers = solveTransfers(twdBalanceMap); // 合併轉帳路徑 (每個付款人 -> 收款人對應的外幣與台幣金額) const combinedTransferMap = {}; foreignTransfers.forEach(t => { const key = `${t.from}_${t.to}`; if (!combinedTransferMap[key]) { combinedTransferMap[key] = { from: t.from, to: t.to, foreignAmount: 0, twdAmount: 0 }; } combinedTransferMap[key].foreignAmount += t.amount; }); twdTransfers.forEach(t => { const key = `${t.from}_${t.to}`; if (!combinedTransferMap[key]) { combinedTransferMap[key] = { from: t.from, to: t.to, foreignAmount: 0, twdAmount: 0 }; } combinedTransferMap[key].twdAmount += t.amount; }); const transfers = Object.values(combinedTransferMap).map(item => { const foreignEquivTwd = Math.round(item.foreignAmount * curRate); const totalAllInTwd = item.twdAmount + foreignEquivTwd; return { ...item, foreignEquivTwd, totalAllInTwd }; }); return { foreignBalanceMap, twdBalanceMap, transfers, curRate, curCfg }; } // Render Settlement Tab function renderSettlement() { const { foreignBalanceMap, twdBalanceMap, transfers, curRate, curCfg } = calculateSettlement(); document.getElementById('settlementTripName').innerText = tripName; document.getElementById('settlementRateNote').innerText = `換算基準:1 ${currentCurrency} = ${curRate.toFixed(4)} TWD`; // 統計台幣總支出與外幣 (日幣) 總支出 (排除自付個人開銷) let sumTwd = 0; let sumForeign = 0; expenses.forEach(curr => { const isPersonal = curr.splitWith.length === 1 && curr.splitWith[0] === curr.payerId; if (isPersonal) return; if (curr.currency === 'TWD') { sumTwd += curr.originalAmount; } else { sumForeign += curr.originalAmount; } }); const foreignEquivalentTwd = Math.round(sumForeign * curRate); const grandTotalTwd = sumTwd + foreignEquivalentTwd; // 更新卡片頭部金額 (3 行清晰結構) const twdSumEl = document.getElementById('settlementTwdSumText'); const jpySumEl = document.getElementById('settlementJpySumText'); const jpyToTwdEl = document.getElementById('settlementJpyToTwdText'); const totalAmountEl = document.getElementById('settlementTotalAmount'); if (twdSumEl) twdSumEl.innerText = `NT$ ${sumTwd.toLocaleString()}`; if (jpySumEl) jpySumEl.innerText = `${curCfg.symbol} ${sumForeign.toLocaleString()} ${currentCurrency}`; if (jpyToTwdEl) jpyToTwdEl.innerText = `(等值約 NT$ ${foreignEquivalentTwd.toLocaleString()})`; if (totalAmountEl) totalAmountEl.innerText = `NT$ ${grandTotalTwd.toLocaleString()}`; // Render balance list (每位成員顯示台幣與日幣獨立收支) const balanceContainer = document.getElementById('memberBalanceList'); balanceContainer.innerHTML = members.map(m => { const fBal = Math.round(foreignBalanceMap[m.id] || 0); const tBal = Math.round(twdBalanceMap[m.id] || 0); const fEquivTwd = Math.round(fBal * curRate); const totalNetTwd = tBal + fEquivTwd; // 外幣部分標籤 let fText = ''; if (fBal !== 0) { const fSign = fBal > 0 ? '+' : ''; const fClass = fBal > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'; fText = `${fSign}${curCfg.symbol} ${Math.abs(fBal).toLocaleString()} ${currentCurrency} (約 NT$ ${Math.abs(fEquivTwd).toLocaleString()})`; } // 台幣部分標籤 let tText = ''; if (tBal !== 0) { const tSign = tBal > 0 ? '+' : ''; const tClass = tBal > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'; tText = `${tSign}NT$ ${Math.abs(tBal).toLocaleString()}`; } let totalClass = totalNetTwd > 0 ? 'text-emerald-400 font-black font-scifi drop-shadow-[0_0_6px_#34d399]' : (totalNetTwd < 0 ? 'text-rose-400 font-black font-scifi drop-shadow-[0_0_6px_#f43f5e]' : 'text-slate-400 font-cyber'); let totalText = totalNetTwd === 0 ? '已結清 BALANCED' : (totalNetTwd > 0 ? `合計應收 +NT$ ${totalNetTwd.toLocaleString()}` : `合計應付 -NT$ ${Math.abs(totalNetTwd).toLocaleString()}`); const cleanName = getMemberCleanName(m.name); const hostTag = isMemberHost(m) ? '房主' : ''; const virtualTag = m.isVirtual ? '代墊' : ''; return `
${cleanName} ${hostTag} ${virtualTag} ${totalText}
${fText ? `日幣:${fText}` : '日幣:無'} ${tText ? `· 台幣:${tText}` : '· 台幣:無'}
`; }).join(''); // Render transfer suggestions (雙軌選擇:現金給日幣+台幣 或 全部折合台幣轉帳) const transferContainer = document.getElementById('transferPlanList'); if (transfers.length === 0) { transferContainer.innerHTML = `
🎉 所有人帳目已完全平衡,無需任何轉帳!
`; return; } transferContainer.innerHTML = transfers.map(t => { const fromMember = members.find(m => m.id === t.from) || { name: '未知' }; const toMember = members.find(m => m.id === t.to) || { name: '未知' }; const hasForeign = t.foreignAmount > 0; const hasTwd = t.twdAmount > 0; return `
${getMemberCleanName(fromMember.name)} ${getMemberCleanName(toMember.name)}
${toMember.bank || 'LINE Pay / 晶片帳號'}
💴 選擇 A(付外幣現鈔+台幣):
${hasForeign ? `${curCfg.symbol} ${t.foreignAmount.toLocaleString()} ${currentCurrency}` : '外幣 0'} ${hasTwd ? ` + NT$ ${t.twdAmount.toLocaleString()}` : ''}
適合身邊有日幣現鈔想結清的夥伴
🇹🇼 選擇 B(全轉帳台幣結清):
NT$ ${t.totalAllInTwd.toLocaleString()}
含台幣 ${t.twdAmount.toLocaleString()} + 日幣折合 ${t.foreignEquivTwd.toLocaleString()}
`; }).join(''); } // Current generated settlement image blob & data url let currentSettlementBlob = null; let currentSettlementDataUrl = ''; // Capture Settlement Card As Image (For LINE sharing) async function captureSettlementImage() { const card = document.getElementById('settlementCardToCapture'); const btnText = document.getElementById('btnCaptureSettlementText'); const btn = document.getElementById('btnCaptureSettlement'); if (!card) return; // Visual feedback on button if (btnText) btnText.innerText = '正在生成圖卡中...'; if (btn) btn.disabled = true; try { if (typeof html2canvas === 'undefined') { throw new Error('html2canvas library not loaded yet'); } const canvas = await html2canvas(card, { scale: 2, useCORS: true, logging: false, allowTaint: true, backgroundColor: isDarkMode ? '#18181b' : '#ffffff' }); currentSettlementDataUrl = canvas.toDataURL('image/png'); canvas.toBlob(blob => { currentSettlementBlob = blob; }, 'image/png'); // Set image to preview modal const previewImg = document.getElementById('settlementPreviewImg'); if (previewImg) { previewImg.src = currentSettlementDataUrl; } // Open preview modal immediately so user sees the generated picture openModal('settlementImageModal'); lucide.createIcons(); // If Web Share API supports file sharing, attempt direct share popup if (navigator.canShare && canvas.toBlob) { canvas.toBlob(async (blob) => { if (!blob) return; const file = new File([blob], `${tripName}_結算帳單.png`, { type: 'image/png' }); if (navigator.canShare({ files: [file] })) { try { await navigator.share({ title: `${tripName} 結算帳單`, text: `這是【${tripName}】的最新分帳結算帳單!`, files: [file] }); } catch (shareErr) { console.log('Share dismissed or cancelled:', shareErr); } } }, 'image/png'); } } catch (err) { console.error('html2canvas error:', err); alert('圖卡生成稍有延遲,請稍候重試或使用手機直接截圖。'); } finally { if (btnText) btnText.innerText = '一鍵生成精美圖卡 (發送至 LINE 群)'; if (btn) btn.disabled = false; } } // Trigger Native Share or Fallback in Modal async function shareOrDownloadSettlementImg() { if (currentSettlementBlob && navigator.canShare) { const file = new File([currentSettlementBlob], `${tripName}_結算帳單.png`, { type: 'image/png' }); if (navigator.canShare({ files: [file] })) { try { await navigator.share({ title: `${tripName} 結算帳單`, text: `這是【${tripName}】的最新分帳結算帳單!`, files: [file] }); return; } catch (e) { console.log('User cancelled share'); } } } // If native sharing is not available, download the file downloadSettlementImgFile(); } // Download Image File function downloadSettlementImgFile() { if (!currentSettlementDataUrl) return; const link = document.createElement('a'); link.download = `${tripName}_結算帳單_${Date.now()}.png`; link.href = currentSettlementDataUrl; document.body.appendChild(link); link.click(); document.body.removeChild(link); alert('✅ 圖片已觸發下載!手機用戶亦可長按圖片直接「儲存至相簿」或「分享」。'); } // Copy Settlement Text for LINE function copySettlementSummaryText() { const { foreignBalanceMap, twdBalanceMap, transfers, curRate, curCfg } = calculateSettlement(); // 統計共同總花費 let sumTwd = 0; let sumForeign = 0; expenses.forEach(curr => { const isPersonal = curr.splitWith.length === 1 && curr.splitWith[0] === curr.payerId; if (isPersonal) return; if (curr.currency === 'TWD') sumTwd += curr.originalAmount; else sumForeign += curr.originalAmount; }); const foreignEquivTwd = Math.round(sumForeign * curRate); const grandTotalTwd = sumTwd + foreignEquivTwd; let text = `📋【${tripName} 結算帳單】\n`; text += `💰 共同總支出:\n`; text += ` • 台幣總計:NT$ ${sumTwd.toLocaleString()}\n`; text += ` • 日幣總計:${curCfg.symbol} ${sumForeign.toLocaleString()} ${currentCurrency}(即時折合約 NT$ ${foreignEquivTwd.toLocaleString()})\n`; text += ` • 合計折合約:NT$ ${grandTotalTwd.toLocaleString()}\n`; text += `📈 換算基準:1 ${currentCurrency} = ${curRate.toFixed(4)} TWD\n\n`; text += `👥 各成員淨收支:\n`; members.forEach(m => { const fBal = Math.round(foreignBalanceMap[m.id] || 0); const tBal = Math.round(twdBalanceMap[m.id] || 0); const totalNet = tBal + Math.round(fBal * curRate); const sign = totalNet >= 0 ? '+' : ''; text += `• ${m.name}:合計 ${sign}NT$ ${totalNet.toLocaleString()} (日幣 ${fBal >= 0 ? '+' : ''}${fBal.toLocaleString()} · 台幣 ${tBal >= 0 ? '+' : ''}${tBal.toLocaleString()})\n`; }); text += `\n👉 結清轉帳建議(提供兩種彈性方式):\n`; if (transfers.length === 0) { text += `🎉 所有人帳目已完全結清,無須轉帳!\n`; } else { transfers.forEach(t => { const from = members.find(m => m.id === t.from)?.name; const to = members.find(m => m.id === t.to)?.name; const toBank = members.find(m => m.id === t.to)?.bank || ''; text += `------------------------\n`; text += `【${from} ➡️ 付給 ${to}】\n`; if (toBank) text += `💳 收款帳號:${toBank}\n`; text += ` 🔹 選擇 A(日幣現鈔+台幣):${t.foreignAmount > 0 ? `${curCfg.symbol} ${t.foreignAmount.toLocaleString()} ${currentCurrency}` : '外幣 0'}${t.twdAmount > 0 ? ` + NT$ ${t.twdAmount.toLocaleString()}` : ''}\n`; text += ` 🔹 選擇 B(全轉帳台幣結清):NT$ ${t.totalAllInTwd.toLocaleString()}(日幣折合 ${t.foreignEquivTwd.toLocaleString()} + 台幣 ${t.twdAmount.toLocaleString()})\n`; }); } navigator.clipboard.writeText(text).then(() => { alert('✅ 詳細結算明細已複製到剪貼簿,可直接貼上至 LINE 群組!'); }); } // Fetch Live Exchange Rate for Active Currency async function fetchLiveRate() { try { const res = await fetch(`https://open.er-api.com/v6/latest/${currentCurrency}`); const data = await res.json(); if (data && data.rates && data.rates.TWD) { const liveRate = parseFloat(data.rates.TWD.toFixed(4)); rates[currentCurrency] = liveRate; persistData(); renderAll(); alert(`✅ 成功獲取即時匯率:1 ${currentCurrency} = ${liveRate} TWD!`); } else { throw new Error('無法取得匯率'); } } catch (err) { alert(`無法連線取得即時匯率,可在設定中手動輸入匯率。`); } } // QR Code Generation (Appends ?guest=true to bypass login for invited friends) function getShareableTripUrl() { const url = new URL(window.location.href); url.searchParams.set('guest', 'true'); url.searchParams.set('join', 'true'); return url.toString(); } function initQR() { const container = document.getElementById('qrcodeCanvas'); if (!container) return; container.innerHTML = ''; const shareUrl = getShareableTripUrl(); new QRCode(container, { text: shareUrl, width: 160, height: 160, colorDark: isDarkMode ? "#ffffff" : "#1e1b4b", colorLight: isDarkMode ? "#27272a" : "#ffffff", correctLevel: QRCode.CorrectLevel.H }); } function copyJoinLink() { const shareUrl = getShareableTripUrl(); navigator.clipboard.writeText(shareUrl).then(() => { alert('✅ 專屬邀請連結已複製!同行朋友直接在手機開啟即可直達記帳,免註冊免登入!'); }); } // Settings Modal Open & Check async function openTripSettingModal() { // 1. 同步當前旅程最新值至輸入欄位 const titleInput = document.getElementById('tripNameInput'); const startInput = document.getElementById('tripStartDateInput'); const endInput = document.getElementById('tripEndDateInput'); if (titleInput) titleInput.value = tripName || ''; if (startInput) startInput.value = tripStartDate || ''; if (endInput) endInput.value = tripEndDate || ''; // 2. 開啟彈窗 openModal('tripSettingModal'); // 3. 即時向 Supabase 進行連線心跳測試 if (supabaseClient) { updateCloudUI('connecting'); try { const { error } = await supabaseClient.from('trips').select('id', { head: true, count: 'exact' }); if (!error) { updateCloudUI('connected', '🟢 已連線'); } else { console.warn('Supabase 連線檢測回應:', error); updateCloudUI('connected', '🟢 已連線'); } } catch (e) { console.warn('Supabase 連線測試異常:', e); updateCloudUI('offline', '⚠️ 離線快取'); } } else { updateCloudUI('offline', '⚠️ 離線快取'); } if (window.lucide) lucide.createIcons(); } // Settings function saveTripSettings() { const newTitle = document.getElementById('tripNameInput').value.trim(); const newStart = document.getElementById('tripStartDateInput').value; const newEnd = document.getElementById('tripEndDateInput').value; if (!newTitle) { alert('請輸入行程名稱!'); return; } if (!newStart || !newEnd) { alert('請選擇出發日期與回程日期!'); return; } if (new Date(newStart) > new Date(newEnd)) { alert('出發日期不能晚於回程日期!'); return; } tripName = newTitle; tripStartDate = newStart; tripEndDate = newEnd; persistData(); renderAll(); closeModal('tripSettingModal'); alert('✅ 行程資訊與日期設定已成功儲存並同步至雲端!'); }