// Tabi — Trip-centric Accounting (共同記帳 / 個人記帳) // ── Shared helpers ─────────────────────────────────────────── const CAT_ICONS = { '餐飲': '🍜', '交通': '🚕', '住宿': '🏨', '景點': '🎢', '購物': '🛍️', '代購': '🎁', '事前': '✈️', '其他': '💳', }; const CAT_LIST = Object.keys(CAT_ICONS); function _fmtDateShort(iso) { if (!iso) return ''; const d = new Date(iso); return `${d.getMonth() + 1}/${d.getDate()}`; } function _fmtDateFull(iso) { if (!iso) return ''; const d = new Date(iso); return `${d.getFullYear()}/${String(d.getMonth()+1).padStart(2,'0')}/${String(d.getDate()).padStart(2,'0')}`; } function _fmtAmt(n, currency) { if (currency === 'JPY') return `¥${Number(n).toLocaleString()}`; if (currency === 'TWD') return `NT$${Number(n).toLocaleString()}`; return `${n}`; } function computeSettlement(members, expenses) { const balances = {}; members.forEach(m => balances[m.id] = 0); expenses.forEach(e => { if (e.visibility !== 'shared') return; let unsettledSum = 0; e.splits.forEach(s => { if (!s.is_settled) { balances[s.member_id] = (balances[s.member_id] || 0) - s.amount; unsettledSum += s.amount; } }); balances[e.paid_by] = (balances[e.paid_by] || 0) + unsettledSum; }); const creditors = [], debtors = []; Object.entries(balances).forEach(([id, b]) => { if (b > 1) creditors.push({ id, b }); else if (b < -1) debtors.push({ id, b: -b }); }); creditors.sort((a, b) => b.b - a.b); debtors.sort((a, b) => b.b - a.b); const transfers = []; let i = 0, j = 0; while (i < debtors.length && j < creditors.length) { const amt = Math.min(debtors[i].b, creditors[j].b); transfers.push({ from: debtors[i].id, to: creditors[j].id, amount: Math.round(amt) }); debtors[i].b -= amt; creditors[j].b -= amt; if (debtors[i].b < 1) i++; if (creditors[j].b < 1) j++; } return { balances, transfers }; } function MemberAvatar({ m, size = 32, ring }) { return (
{m.initial || m.display_name?.[0] || '?'}
); } // ── Shared sheet wrapper ────────────────────────────────────── // Uses a React portal into document.body so position:fixed escapes the // overflow:auto stacking context of .tabi-screen (iOS Safari quirk). function SheetWrap({ title, onClose, children, action }) { return ReactDOM.createPortal(
e.target === e.currentTarget && onClose()}>
{/* Header — never scrolls */}
{title}
{/* Scrollable content */}
{children}
{/* Action footer — always visible, respects home indicator */} {action && (
{action}
)}
, document.body ); } // ── Add Expense Sheet ───────────────────────────────────────── function AddExpenseSheet({ trip, onClose, onSaved, initialItems, user }) { const myMember = user ? trip.members.find(m => m.user_id === user.id) : null; const [title, setTitle] = React.useState(''); const [amount, setAmount] = React.useState(''); const [category, setCategory] = React.useState('餐飲'); const [paidBy, setPaidBy] = React.useState(myMember?.id || trip.members[0]?.id || ''); const [visibility, setVisibility] = React.useState('personal'); const [splitMethod, setSplitMethod] = React.useState('equal'); const [customSplits, setCustomSplits] = React.useState(() => trip.members.map(m => ({ memberId: m.id, amount: '', settled: false })) ); const [equalSettled, setEqualSettled] = React.useState({}); const [isProxy, setIsProxy] = React.useState(false); const [proxyFor, setProxyFor] = React.useState(''); const [expenseDate, setExpenseDate] = React.useState( new Date().toISOString().slice(0, 10) ); const [inputCurrency, setInputCurrency] = React.useState(trip.currency); const [convertedAmt, setConvertedAmt] = React.useState(null); const [fetchingRate, setFetchingRate] = React.useState(false); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(''); const [showItems, setShowItems] = React.useState(!!(initialItems && initialItems.length > 0)); const [itemRows, setItemRows] = React.useState( initialItems && initialItems.length > 0 ? initialItems.map(it => ({ name: it.translated_name || it.original_name || '', qty: it.qty || 1, price: String(it.subtotal || '') })) : [{ name: '', qty: 1, price: '' }] ); function addItemRow() { setItemRows(prev => [...prev, { name: '', qty: 1, price: '' }]); } function removeItemRow(i) { setItemRows(prev => prev.filter((_, idx) => idx !== i)); } function updateItemRow(i, field, val) { setItemRows(prev => prev.map((r, idx) => idx === i ? { ...r, [field]: val } : r)); } React.useEffect(() => { if (!showItems) return; const total = itemRows.reduce((s, r) => s + (parseInt(r.price) || 0) * (parseInt(r.qty) || 1), 0); if (total > 0) setAmount(String(total)); }, [itemRows, showItems]); React.useEffect(() => { if (inputCurrency === trip.currency || !amount || parseInt(amount) <= 0) { setConvertedAmt(null); return; } let cancelled = false; setFetchingRate(true); fetch(`/api/trips/exchange-rate?from_currency=${inputCurrency}&to_currency=${trip.currency}&date=${expenseDate}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` }, }) .then(r => r.json()) .then(d => { if (!cancelled && d.rate) setConvertedAmt(Math.round(parseInt(amount) * d.rate)); }) .catch(() => { if (!cancelled) setConvertedAmt(null); }) .finally(() => { if (!cancelled) setFetchingRate(false); }); return () => { cancelled = true; }; }, [inputCurrency, amount, expenseDate]); const isCustom = visibility === 'shared' && splitMethod === 'custom'; const customTotal = customSplits.reduce((s, cs) => s + (parseInt(cs.amount) || 0), 0); function handleSplitMethod(v) { if (v !== 'custom' && splitMethod === 'custom' && customTotal > 0) setAmount(String(customTotal)); setSplitMethod(v); } async function save() { if (!title.trim()) { setError('請輸入名稱'); return; } const rawAmt = isCustom ? customTotal : parseInt(amount, 10); const amt = (inputCurrency !== trip.currency && convertedAmt) ? convertedAmt : rawAmt; if (!amt || amt <= 0) { setError(isCustom ? '請輸入各人金額' : '請輸入金額'); return; } setSaving(true); try { const items = showItems ? itemRows.filter(r => r.name.trim()).map(r => ({ original_name: r.name.trim(), translated_name: r.name.trim(), qty: parseInt(r.qty) || 1, subtotal: parseInt(r.price) || 0, })) : null; const equalSettledIds = visibility === 'shared' && splitMethod === 'equal' ? trip.members.filter(m => equalSettled[m.id]).map(m => m.id) : []; const useCustomForEqual = equalSettledIds.length > 0; const body = { title: title.trim(), amount: amt, currency: trip.currency, paid_by: paidBy, category, icon: CAT_ICONS[category] || '💳', split_method: visibility === 'personal' ? 'solo' : (useCustomForEqual ? 'custom' : splitMethod), visibility, is_proxy: isProxy, proxy_for: isProxy ? proxyFor : null, expense_date: new Date(expenseDate).toISOString(), items, splits: isCustom ? Object.fromEntries(customSplits.filter(cs => parseInt(cs.amount) > 0).map(cs => [cs.memberId, parseInt(cs.amount)])) : useCustomForEqual ? Object.fromEntries(trip.members.map(m => [m.id, Math.round(amt / trip.members.length)])) : undefined, settled_members: isCustom ? customSplits.filter(cs => cs.settled && parseInt(cs.amount) > 0).map(cs => cs.memberId) : useCustomForEqual ? equalSettledIds : undefined, }; const r = await fetch(`/api/trips/${trip.id}/expenses`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify(body), }); if (!r.ok) throw new Error(await r.text()); onSaved(); } catch (e) { setError('儲存失敗:' + e.message); setSaving(false); } } const inputStyle = { width: '100%', padding: '10px 12px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg, fontSize: 16, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }; const _equalMemberPanel = (visibility === 'shared' && splitMethod === 'equal') ? (
{trip.members.map(m => { const isPayer = m.id === paidBy; const settled = !!equalSettled[m.id]; const eAmt = parseInt(amount) > 0 ? Math.round(parseInt(amount) / trip.members.length) : null; return (
{(m.display_name || '?')[0]}
{m.display_name}
{eAmt !== null &&
¥{eAmt.toLocaleString()}
} {isPayer ? (
付款人
) : ( )}
); })}
) : null; return ( {error &&
{error}
} }> {/* Title */}
名稱
setTitle(e.target.value)} placeholder="例:一蘭拉麵" style={inputStyle} />
{/* Currency selector */}
支付幣別
{[trip.currency, 'TWD'].filter((v, i, a) => a.indexOf(v) === i).map(c => ( ))}
{/* Amount — hidden when custom (total derived from per-member splits) */} {!isCustom && (
金額({inputCurrency})
setAmount(e.target.value)} placeholder="0" style={inputStyle} inputMode="numeric" />
)} {!isCustom && inputCurrency !== trip.currency && (
{fetchingRate ? '換算中…' : convertedAmt !== null ? `≈ ${trip.currency === 'JPY' ? '¥' : ''}${convertedAmt.toLocaleString()} ${trip.currency}` : '請輸入金額'}
)} {/* Date */}
日期
setExpenseDate(e.target.value)} style={inputStyle} />
{/* Category */}
分類
{/* Paid by — only relevant for shared expenses */} {visibility === 'shared' && (
誰先付
{trip.members.map(m => ( ))}
)} {/* Visibility toggle */}
可見範圍
{[['personal', '個人'], ['shared', '共同']].map(([v, label]) => ( ))}
{/* Split method (shared only) */} {visibility === 'shared' && (
分攤方式
{[['equal', '均分'], ['solo', '僅我'], ['custom', '自訂金額']].map(([v, label]) => ( ))}
)} {/* Equal split: member settled toggles */} {_equalMemberPanel} {/* Custom per-member amount inputs */} {isCustom && (
{trip.members.map(m => { const cs = customSplits.find(s => s.memberId === m.id) || { memberId: m.id, amount: '', settled: false }; const isPayer = m.id === paidBy; return (
{(m.display_name || '?')[0]}
{m.display_name}
¥ setCustomSplits(prev => prev.map(s => s.memberId === m.id ? {...s, amount: e.target.value} : s))} style={{ width: '100%', padding: '7px 8px 7px 22px', borderRadius: 9, border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.card, fontSize: 16, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box' }} />
{isPayer ? (
付款人
) : ( )}
); })}
合計 ¥{customTotal.toLocaleString()}
)} {/* Proxy toggle — personal only */} {visibility === 'personal' && ( <>
代購
幫別人代為購買
setIsProxy(!isProxy)} style={{ width: 44, height: 24, borderRadius: 12, cursor: 'pointer', background: isProxy ? TABI_COLORS.red : TABI_COLORS.line2, position: 'relative', transition: 'background 0.2s', flexShrink: 0, }}>
{isProxy && (
代購對象
setProxyFor(e.target.value)} placeholder="誰的代購品?" style={inputStyle} />
)} )} {/* Items detail (optional) */}
明細(選填)
{showItems && ( <> {itemRows.map((row, i) => (
updateItemRow(i, 'name', e.target.value)} placeholder="品項名稱" style={{ ...inputStyle, flex: 3, padding: '8px 10px', fontSize: 16 }} /> updateItemRow(i, 'qty', e.target.value)} placeholder="數" style={{ ...inputStyle, flex: 1, padding: '8px 10px', fontSize: 16 }} inputMode="numeric" /> updateItemRow(i, 'price', e.target.value)} placeholder="金額" style={{ ...inputStyle, flex: 2, padding: '8px 10px', fontSize: 16 }} inputMode="numeric" />
))}
小計:{_fmtAmt(itemRows.reduce((s, r) => s + (parseInt(r.price) || 0) * (parseInt(r.qty) || 1), 0), trip.currency)}
)}
); } // ── Edit Expense Sheet ──────────────────────────────────────── function EditExpenseSheet({ trip, expense, onClose, onSaved, onDeleted }) { const [title, setTitle] = React.useState(expense.title); const [amount, setAmount] = React.useState(String(expense.amount)); const [category, setCategory] = React.useState(expense.category || '餐飲'); const [paidBy, setPaidBy] = React.useState(expense.paid_by); const [visibility, setVisibility] = React.useState(expense.visibility || 'personal'); const [splitMethod, setSplitMethod] = React.useState(expense.split_method || 'equal'); const [customSplits, setCustomSplits] = React.useState(() => { if (expense.split_method === 'custom' && expense.splits?.length) { return trip.members.map(m => { const s = expense.splits.find(x => x.member_id === m.id); return { memberId: m.id, amount: s ? String(s.amount) : '', settled: s?.is_settled || false }; }); } return trip.members.map(m => ({ memberId: m.id, amount: '', settled: false })); }); const [equalSettled, setEqualSettled] = React.useState(() => { if (expense.splits?.length) { const s = {}; expense.splits.forEach(sp => { if (sp.is_settled) s[sp.member_id] = true; }); return s; } return {}; }); const [inputCurrency, setInputCurrency] = React.useState(expense.currency || trip.currency); const [convertedAmt, setConvertedAmt] = React.useState(null); const [fetchingRate, setFetchingRate] = React.useState(false); const [isProxy, setIsProxy] = React.useState(expense.is_proxy || false); const [proxyFor, setProxyFor] = React.useState(expense.proxy_for || ''); const [expenseDate, setExpenseDate] = React.useState( expense.expense_date ? expense.expense_date.slice(0, 10) : new Date().toISOString().slice(0, 10) ); const [saving, setSaving] = React.useState(false); const [deleting, setDeleting] = React.useState(false); const [error, setError] = React.useState(''); const [showItems, setShowItems] = React.useState(!!(expense.items && expense.items.length > 0)); const [itemRows, setItemRows] = React.useState( expense.items && expense.items.length > 0 ? expense.items.map(it => ({ name: it.translated_name || it.original_name || '', qty: it.qty || 1, price: String(it.subtotal || '') })) : [{ name: '', qty: 1, price: '' }] ); function addItemRow() { setItemRows(prev => [...prev, { name: '', qty: 1, price: '' }]); } function removeItemRow(i) { setItemRows(prev => prev.filter((_, idx) => idx !== i)); } function updateItemRow(i, field, val) { setItemRows(prev => prev.map((r, idx) => idx === i ? { ...r, [field]: val } : r)); } React.useEffect(() => { if (!showItems) return; const total = itemRows.reduce((s, r) => s + (parseInt(r.price) || 0) * (parseInt(r.qty) || 1), 0); if (total > 0) setAmount(String(total)); }, [itemRows, showItems]); React.useEffect(() => { if (inputCurrency === trip.currency || !amount || parseInt(amount) <= 0) { setConvertedAmt(null); return; } let cancelled = false; setFetchingRate(true); fetch(`/api/trips/exchange-rate?from_currency=${inputCurrency}&to_currency=${trip.currency}&date=${expenseDate}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` }, }) .then(r => r.json()) .then(d => { if (!cancelled && d.rate) setConvertedAmt(Math.round(parseInt(amount) * d.rate)); }) .catch(() => { if (!cancelled) setConvertedAmt(null); }) .finally(() => { if (!cancelled) setFetchingRate(false); }); return () => { cancelled = true; }; }, [inputCurrency, amount, expenseDate]); const isCustom = visibility === 'shared' && splitMethod === 'custom'; const customTotal = customSplits.reduce((s, cs) => s + (parseInt(cs.amount) || 0), 0); function handleSplitMethod(v) { if (v !== 'custom' && splitMethod === 'custom' && customTotal > 0) setAmount(String(customTotal)); setSplitMethod(v); } async function save() { if (!title.trim()) { setError('請輸入名稱'); return; } const rawAmt = isCustom ? customTotal : parseInt(amount, 10); const amt = (inputCurrency !== trip.currency && convertedAmt) ? convertedAmt : rawAmt; if (!amt || amt <= 0) { setError(isCustom ? '請輸入各人金額' : '請輸入金額'); return; } setSaving(true); try { const items = showItems ? itemRows.filter(r => r.name.trim()).map(r => ({ original_name: r.name.trim(), translated_name: r.name.trim(), qty: parseInt(r.qty) || 1, subtotal: parseInt(r.price) || 0, })) : null; const eqSettledIds = visibility === 'shared' && splitMethod === 'equal' ? trip.members.filter(m => equalSettled[m.id]).map(m => m.id) : []; const useCustomForEqual = eqSettledIds.length > 0; const body = { title: title.trim(), amount: amt, currency: trip.currency, paid_by: paidBy, category, icon: CAT_ICONS[category] || '💳', split_method: visibility === 'personal' ? 'solo' : (useCustomForEqual ? 'custom' : splitMethod), visibility, is_proxy: isProxy, proxy_for: isProxy ? proxyFor : null, expense_date: new Date(expenseDate).toISOString(), items, splits: isCustom ? Object.fromEntries(customSplits.filter(cs => parseInt(cs.amount) > 0).map(cs => [cs.memberId, parseInt(cs.amount)])) : useCustomForEqual ? Object.fromEntries(trip.members.map(m => [m.id, Math.round(amt / trip.members.length)])) : undefined, settled_members: isCustom ? customSplits.filter(cs => cs.settled && parseInt(cs.amount) > 0).map(cs => cs.memberId) : useCustomForEqual ? eqSettledIds : undefined, }; const r = await fetch(`/api/trips/${trip.id}/expenses/${expense.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify(body), }); if (!r.ok) throw new Error(await r.text()); onSaved(); } catch (e) { setError('儲存失敗:' + e.message); setSaving(false); } } async function del() { if (!confirm('確定要刪除這筆支出?')) return; setDeleting(true); try { const r = await fetch(`/api/trips/${trip.id}/expenses/${expense.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${window.tabiToken()}` }, }); if (!r.ok) throw new Error(await r.text()); onDeleted(); } catch (e) { setError('刪除失敗:' + e.message); setDeleting(false); } } const inputStyle = { width: '100%', padding: '10px 12px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg, fontSize: 16, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }; const _equalMemberPanel = (visibility === 'shared' && splitMethod === 'equal') ? (
{trip.members.map(m => { const isPayer = m.id === paidBy; const settled = !!equalSettled[m.id]; const eAmt = parseInt(amount) > 0 ? Math.round(parseInt(amount) / trip.members.length) : null; return (
{(m.display_name || '?')[0]}
{m.display_name}
{eAmt !== null &&
¥{eAmt.toLocaleString()}
} {isPayer ? (
付款人
) : ( )}
); })}
) : null; return ( {error &&
{error}
}
}> {/* Title */}
名稱
setTitle(e.target.value)} placeholder="例:一蘭拉麵" style={inputStyle} />
{/* Currency selector */}
支付幣別
{[trip.currency, 'TWD'].filter((v, i, a) => a.indexOf(v) === i).map(c => ( ))}
{/* Amount — hidden when custom */} {!isCustom && (
金額({inputCurrency})
setAmount(e.target.value)} placeholder="0" style={inputStyle} inputMode="numeric" />
)} {!isCustom && inputCurrency !== trip.currency && (
{fetchingRate ? '換算中…' : convertedAmt !== null ? `≈ ${trip.currency === 'JPY' ? '¥' : ''}${convertedAmt.toLocaleString()} ${trip.currency}` : '請輸入金額'}
)} {/* Date */}
日期
setExpenseDate(e.target.value)} style={inputStyle} />
{/* Category */}
分類
{/* Paid by — only relevant for shared expenses */} {visibility === 'shared' && (
誰先付
{trip.members.map(m => ( ))}
)} {/* Visibility toggle */}
可見範圍
{[['personal', '個人'], ['shared', '共同']].map(([v, label]) => ( ))}
{/* Split method (shared only) */} {visibility === 'shared' && (
分攤方式
{[['equal', '均分'], ['solo', '僅我'], ['custom', '自訂金額']].map(([v, label]) => ( ))}
)} {/* Equal split: member settled toggles */} {_equalMemberPanel} {/* Custom per-member amount inputs */} {isCustom && (
{trip.members.map(m => { const cs = customSplits.find(s => s.memberId === m.id) || { memberId: m.id, amount: '', settled: false }; const isPayer = m.id === paidBy; return (
{(m.display_name || '?')[0]}
{m.display_name}
¥ setCustomSplits(prev => prev.map(s => s.memberId === m.id ? {...s, amount: e.target.value} : s))} style={{ width: '100%', padding: '7px 8px 7px 22px', borderRadius: 9, border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.card, fontSize: 16, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box' }} />
{isPayer ? (
付款人
) : ( )}
); })}
合計 ¥{customTotal.toLocaleString()}
)} {/* Proxy toggle — personal only */} {visibility === 'personal' && ( <>
代購
幫別人代為購買
setIsProxy(!isProxy)} style={{ width: 44, height: 24, borderRadius: 12, cursor: 'pointer', background: isProxy ? TABI_COLORS.red : TABI_COLORS.line2, position: 'relative', transition: 'background 0.2s', flexShrink: 0, }}>
{isProxy && (
代購對象
setProxyFor(e.target.value)} placeholder="誰的代購品?" style={inputStyle} />
)} )} {/* Items detail (optional) */}
明細(選填)
{showItems && ( <> {itemRows.map((row, i) => (
updateItemRow(i, 'name', e.target.value)} placeholder="品項名稱" style={{ ...inputStyle, flex: 3, padding: '8px 10px', fontSize: 16 }} /> updateItemRow(i, 'qty', e.target.value)} placeholder="數" style={{ ...inputStyle, flex: 1, padding: '8px 10px', fontSize: 16 }} inputMode="numeric" /> updateItemRow(i, 'price', e.target.value)} placeholder="金額" style={{ ...inputStyle, flex: 2, padding: '8px 10px', fontSize: 16 }} inputMode="numeric" />
))}
小計:{_fmtAmt(itemRows.reduce((s, r) => s + (parseInt(r.price) || 0) * (parseInt(r.qty) || 1), 0), trip.currency)}
)}
); } // ── Create Trip Sheet ───────────────────────────────────────── const MEMBER_PALETTE = ['#1A4D8F', '#5B7553', '#D9A441', '#7B5EA7', '#2E8B8B', '#B85C38']; function CreateTripSheet({ onClose, onCreated }) { const [name, setName] = React.useState(''); const [destination, setDest] = React.useState(''); const [startDate, setStart] = React.useState(''); const [endDate, setEnd] = React.useState(''); const [currency, setCurrency] = React.useState('JPY'); const [members, setMembers] = React.useState([{ name: '', color: MEMBER_PALETTE[0] }]); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(''); const inputStyle = { width: '100%', padding: '10px 12px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg, fontSize: 16, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }; function addMember() { setMembers(prev => [...prev, { name: '', color: MEMBER_PALETTE[prev.length % MEMBER_PALETTE.length] }]); } function removeMember(i) { setMembers(prev => prev.filter((_, idx) => idx !== i)); } function updateMember(i, name) { setMembers(prev => prev.map((m, idx) => idx === i ? { ...m, name } : m)); } async function create() { if (!name.trim()) { setError('請輸入旅行名稱'); return; } setSaving(true); try { const initial_members = members.filter(m => m.name.trim()).map(m => ({ display_name: m.name.trim(), color: m.color })); const r = await fetch('/api/trips', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify({ name: name.trim(), destination: destination.trim() || null, currency, start_date: startDate || null, end_date: endDate || null, initial_members, }), }); if (!r.ok) throw new Error(await r.text()); const { id } = await r.json(); onCreated(id); } catch (e) { setError('建立失敗:' + e.message); setSaving(false); } } return ( {error &&
{error}
} }>
旅行名稱
setName(e.target.value)} placeholder="例:東京春櫻 5 日" style={inputStyle} />
目的地
setDest(e.target.value)} placeholder="例:東京、大阪" style={inputStyle} />
旅行日期
幣別
{/* Member list */}
旅伴(可日後邀請替換)
{members.map((m, i) => (
{m.name?.[0] || '?'}
updateMember(i, e.target.value)} placeholder={`旅伴 ${i + 1} 名稱`} style={{ ...inputStyle, flex: 1, padding: '8px 12px' }} />
))}
旅伴還未加入時先佔位,邀請後可認領帳號,分攤人數即時正確
); } // ── Join Trip Sheet ─────────────────────────────────────────── function JoinTripSheet({ onClose, onJoined, initialCode }) { const [code, setCode] = React.useState(initialCode || ''); const [preview, setPreview] = React.useState(null); // trip preview from server const [claimId, setClaimId] = React.useState(null); // selected member to claim const [loading, setLoading] = React.useState(false); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(''); React.useEffect(() => { if (initialCode) lookup(); }, []); async function lookup() { if (!code.trim() && !initialCode) { setError('請輸入邀請碼'); return; } const lookupCode = (code.trim() || initialCode || '').toUpperCase(); setLoading(true); setError(''); try { const r = await fetch(`/api/trips/preview?code=${lookupCode}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` }, }); if (!r.ok) { const d = await r.json(); throw new Error(d.detail || '查詢失敗'); } const data = await r.json(); if (data.already_member) throw new Error('你已經是這個旅行的成員'); setPreview(data); } catch (e) { setError(e.message); } finally { setLoading(false); } } async function join() { setSaving(true); setError(''); try { const r = await fetch('/api/trips/join', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify({ code: (code.trim() || initialCode || '').toUpperCase(), claim_member_id: claimId || null }), }); if (!r.ok) { const d = await r.json(); throw new Error(d.detail || '加入失敗'); } const data = await r.json(); onJoined(data.trip_id, data.trip_name); } catch (e) { setError(e.message); setSaving(false); } } return ( {error &&
{error}
} {!preview ? : } }> {/* Step 1: enter code */}
邀請碼
{ setCode(e.target.value.toUpperCase()); setPreview(null); setError(''); }} placeholder="例:AB12CD34" style={{ width: '100%', padding: '14px 12px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg, fontSize: 20, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'monospace', letterSpacing: '0.18em', textAlign: 'center', boxSizing: 'border-box', }} />
{/* Step 2: preview + claim */} {preview && ( <>
{preview.trip_name}
{preview.destination &&
{preview.destination}
}
{preview.unclaimed_members.length > 0 && (
你是其中哪位旅伴?
選擇對應的佔位名稱,你的帳號將認領該位置;或跳過直接加入
{preview.unclaimed_members.map(m => (
setClaimId(claimId === m.id ? null : m.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10, marginBottom: 8, cursor: 'pointer', border: `2px solid ${claimId === m.id ? m.color : TABI_COLORS.line}`, background: claimId === m.id ? `${m.color}15` : TABI_COLORS.card, }}>
{m.initial}
{m.display_name}
{claimId === m.id &&
}
))}
)} )}
); } // ── Receipt Scan Sheet ──────────────────────────────────────── function ScanReceiptSheet({ trip: propTrip, trips, onClose, onSaved, onTripCreated }) { const [scanning, setScanning] = React.useState(false); const [result, setResult] = React.useState(null); const [itemStates, setItemStates] = React.useState([]); const [itemAssignments, setItemAssignments] = React.useState([]); const [memberSettled, setMemberSettled] = React.useState({}); const [editableItems, setEditableItems] = React.useState([]); const [sharedSplitMethod, setSharedSplitMethod] = React.useState('equal'); const [saving, setSaving] = React.useState(false); const [scope, setScope] = React.useState('personal'); const [category, setCategory] = React.useState('餐飲'); const [paidBy, setPaidBy] = React.useState(propTrip?.members[0]?.id || ''); const [showCreateTrip, setShowCreateTrip] = React.useState(false); const [error, setError] = React.useState(''); // Home-screen trip selection const [selectedTripId, setSelectedTripId] = React.useState(null); const [selectedTripData, setSelectedTripData] = React.useState(null); const [loadingTrip, setLoadingTrip] = React.useState(false); const cameraRef = React.useRef(); const galleryRef = React.useRef(); // Effective trip object — from prop (trip detail) or fetched from home selection const trip = propTrip || selectedTripData; const tripId = trip?.id; React.useEffect(() => { if (trip?.members?.length > 0) setPaidBy(trip.members[0].id); }, [trip?.id]); async function onSelectTrip(id) { setSelectedTripId(id); setSelectedTripData(null); if (!id) return; setLoadingTrip(true); try { const r = await fetch(`/api/trips/${id}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` } }); if (r.ok) setSelectedTripData(await r.json()); } catch (_) {} finally { setLoadingTrip(false); } } function initItemStates(items) { return (items || []).map(() => ({ isProxy: false, proxyFor: '' })); } function setItemProxy(i, isProxy) { setItemStates(prev => prev.map((s, idx) => idx === i ? { ...s, isProxy } : s)); } function setItemProxyFor(i, proxyFor) { setItemStates(prev => prev.map((s, idx) => idx === i ? { ...s, proxyFor } : s)); } function addEditableItem() { setEditableItems(prev => [...prev, { translated_name: '', qty: 1, subtotal: 0 }]); setItemStates(prev => [...prev, { isProxy: false, proxyFor: '' }]); setItemAssignments(prev => [...prev, null]); } function removeEditableItem(i) { setEditableItems(prev => prev.filter((_, idx) => idx !== i)); setItemStates(prev => prev.filter((_, idx) => idx !== i)); setItemAssignments(prev => prev.filter((_, idx) => idx !== i)); } function updateEditableItem(i, field, val) { setEditableItems(prev => prev.map((it, idx) => idx === i ? { ...it, [field]: val } : it)); } async function handleFile(file) { if (!file) return; setScanning(true); setError(''); setResult(null); setItemStates([]); try { const fd = new FormData(); fd.append('image', file); const r = await fetch('/api/scan/receipt', { method: 'POST', headers: { Authorization: `Bearer ${window.tabiToken()}`, ...window.tabiLLMHeaders() }, body: fd, }); if (!r.ok) { const d = await r.json(); if (d.detail === 'NOT_A_RECEIPT') throw new Error('這不是收據,請重新拍攝'); throw new Error(d.detail || '掃描失敗'); } const data = await r.json(); setResult(data); setItemStates(initItemStates(data.items)); setItemAssignments((data.items || []).map(() => null)); setMemberSettled({}); setEditableItems((data.items || []).map(it => ({ translated_name: it.translated_name || it.original_name || '', qty: it.qty || 1, subtotal: it.subtotal || 0, }))); setSharedSplitMethod('equal'); if (data.scope) setScope(data.scope); if (data.category) setCategory(data.category); } catch (e) { setError(e.message); } finally { setScanning(false); } } async function _postExpense(tripId, body) { const r = await fetch(`/api/trips/${tripId}/expenses`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify(body), }); if (!r.ok) throw new Error(await r.text()); } async function saveReceipt() { if (!result) return; setSaving(true); try { // 1. Save receipt record const rr = await fetch('/api/receipts', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify({ image_url: result.image_url, store_name: result.store_name, total_amount: editableItems.reduce((s, it) => s + (parseFloat(it.subtotal) || 0), 0) || result.total_amount, tax_amount: result.tax_amount, receipt_date: result.receipt_date, items: editableItems.map(it => ({ original_name: it.translated_name, translated_name: it.translated_name, qty: it.qty, subtotal: parseFloat(it.subtotal) || 0 })), }), }); if (!rr.ok) throw new Error(await rr.text()); const { id: receiptId } = await rr.json(); if (tripId) { // 2. Link receipt to trip await fetch(`/api/trips/${tripId}/receipts`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify({ receipt_id: receiptId, scope }), }); const items = editableItems; const editedTotal = items.reduce((s, it) => s + (parseFloat(it.subtotal) || 0), 0); const expDate = result.receipt_date ? new Date(result.receipt_date).toISOString() : new Date().toISOString(); const base = { currency: trip.currency, paid_by: paidBy, expense_date: expDate, receipt_id: receiptId, }; const toItemRow = it => ({ original_name: it.translated_name, translated_name: it.translated_name, qty: it.qty, subtotal: parseFloat(it.subtotal) || 0 }); if (scope === 'shared') { // ── Shared mode ── const settledMembers = trip.members.filter(m => memberSettled[m.id]).map(m => m.id); if (sharedSplitMethod === 'equal') { await _postExpense(tripId, { ...base, title: result.store_name || '收據支出', amount: editedTotal, category, icon: CAT_ICONS[category] || '💳', split_method: settledMembers.length > 0 ? 'custom' : 'equal', visibility: 'shared', is_proxy: false, proxy_for: null, splits: settledMembers.length > 0 ? Object.fromEntries(trip.members.map(m => [m.id, Math.round(editedTotal / trip.members.length)])) : undefined, settled_members: settledMembers, items: items.length > 0 ? items.map(toItemRow) : null, }); } else { // Custom (per-item assignment) if (items.length === 0) { await _postExpense(tripId, { ...base, title: result.store_name || '收據支出', amount: editedTotal, category, icon: CAT_ICONS[category] || '💳', split_method: 'equal', visibility: 'shared', is_proxy: false, proxy_for: null, items: null, }); } else { const memberAmounts = {}; const unassignedTotal = items.reduce((s, item, i) => { if (itemAssignments[i] === null) return s + (parseFloat(item.subtotal) || 0); const mid = itemAssignments[i]; memberAmounts[mid] = (memberAmounts[mid] || 0) + (parseFloat(item.subtotal) || 0); return s; }, 0); if (unassignedTotal > 0 && trip.members.length > 0) { const perMember = unassignedTotal / trip.members.length; trip.members.forEach(m => { memberAmounts[m.id] = (memberAmounts[m.id] || 0) + perMember; }); } const splits = {}; Object.entries(memberAmounts).forEach(([mid, a]) => { if (a > 0) splits[mid] = Math.round(a * 100) / 100; }); await _postExpense(tripId, { ...base, title: result.store_name || '收據支出', amount: editedTotal, category, icon: CAT_ICONS[category] || '💳', split_method: Object.keys(splits).length > 0 ? 'custom' : 'equal', visibility: 'shared', is_proxy: false, proxy_for: null, splits: Object.keys(splits).length > 0 ? splits : undefined, settled_members: settledMembers, items: items.map(toItemRow), }); } } } else { // ── Personal mode: proxy groups ── if (items.length === 0) { await _postExpense(tripId, { ...base, title: result.store_name || '收據支出', amount: editedTotal || result.total_amount || 0, category, icon: CAT_ICONS[category] || '💳', split_method: 'solo', visibility: 'personal', is_proxy: false, proxy_for: null, items: null, }); } else { const normalItems = items.filter((_, i) => !itemStates[i]?.isProxy); const proxyGroups = {}; items.forEach((item, i) => { if (itemStates[i]?.isProxy) { const person = itemStates[i].proxyFor.trim() || '代購'; if (!proxyGroups[person]) proxyGroups[person] = []; proxyGroups[person].push(item); } }); const hasProxy = Object.keys(proxyGroups).length > 0; const normalSubtotal = normalItems.reduce((s, it) => s + (parseFloat(it.subtotal) || 0), 0); const normalAmt = hasProxy ? normalSubtotal : (editedTotal || result.total_amount || normalSubtotal); if (normalAmt > 0) { await _postExpense(tripId, { ...base, title: result.store_name || '收據支出', amount: normalAmt, category, icon: CAT_ICONS[category] || '💳', split_method: 'solo', visibility: 'personal', is_proxy: false, proxy_for: null, items: normalItems.map(toItemRow), }); } for (const [person, proxyItems] of Object.entries(proxyGroups)) { const a = proxyItems.reduce((s, it) => s + (parseFloat(it.subtotal) || 0), 0); if (a > 0) { await _postExpense(tripId, { ...base, title: `${result.store_name || '收據'} (代購)`, amount: a, category: '代購', icon: CAT_ICONS['代購'], split_method: 'solo', visibility: 'personal', is_proxy: true, proxy_for: person, items: proxyItems.map(toItemRow), }); } } } } } onSaved(); } catch (e) { setError('儲存失敗:' + e.message); setSaving(false); } } const footerAction = result ? ( <> {error &&
{error}
}
) : null; return ( { handleFile(e.target.files[0]); e.target.value = ''; }} /> { handleFile(e.target.files[0]); e.target.value = ''; }} /> {/* Home mode with no trips at all */} {trips && !propTrip && trips.length === 0 && !selectedTripId && (
✈️
還沒有旅程
掃描發票需要指定旅程
)} {showCreateTrip && ReactDOM.createPortal( setShowCreateTrip(false)} onCreated={async (id) => { setShowCreateTrip(false); onTripCreated?.(); await onSelectTrip(id); }} />, document.body )} {(!trips || propTrip || trips.length > 0 || selectedTripId) && !result && !scanning && ( <>
🧾
選擇發票圖片
支援 JPG / PNG / HEIC
{error &&
{error}
} )} {scanning && (
辨識中…
)} {result && ( <> {/* Photo thumbnail */} {result.image_url && ( 收據 )} {/* Store header */}
{result.store_name || '未知店家'}
{result.receipt_date && (
{result.receipt_date}
)}
{/* Trip selector (home mode) */} {trips && !propTrip && (
選擇旅程
{trips.map(t => ( ))}
{loadingTrip &&
載入旅程資訊…
}
)} {/* Category + Scope + Content — only when trip is known */} {trip && ( <> {/* Category picker */}
支出分類 AI 建議
{['餐飲','交通','住宿','景點','購物','其他'].map(c => ( ))}
{/* Scope toggle */}
支出類型 AI 建議
{[['personal', '個人'], ['shared', '共同分帳']].map(([v, label]) => ( ))}
{/* ── Editable item list (shared between both modes) ── */} {(() => { const editedTotal = editableItems.reduce((s, it) => s + (parseFloat(it.subtotal) || 0), 0); return (
{scope === 'personal' ? '明細 — 點「代購」標記代購支出' : '明細'} 可編輯
{editableItems.map((item, i) => { const st = itemStates[i] || { isProxy: false, proxyFor: '' }; const assignedId = itemAssignments[i] ?? null; return (
updateEditableItem(i, 'translated_name', e.target.value)} placeholder="品項名稱" style={{ flex: 1, padding: '5px 8px', borderRadius: 7, border: `1px solid ${st.isProxy ? `${TABI_COLORS.red}50` : TABI_COLORS.line}`, background: st.isProxy ? `${TABI_COLORS.red}06` : TABI_COLORS.bg, fontSize: 16, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'inherit', }} />
¥ updateEditableItem(i, 'subtotal', e.target.value)} placeholder="0" style={{ width: '100%', padding: '5px 4px 5px 16px', borderRadius: 7, border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg, fontSize: 16, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }} />
{scope === 'personal' && ( )}
{/* Personal: proxy name input */} {scope === 'personal' && st.isProxy && ( setItemProxyFor(i, e.target.value)} placeholder="代購對象(輸入姓名)" style={{ marginTop: 5, width: '100%', padding: '6px 10px', borderRadius: 8, border: `1px solid ${TABI_COLORS.red}50`, background: `${TABI_COLORS.red}08`, fontSize: 16, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }} /> )} {/* Shared custom: member assignment pills */} {scope === 'shared' && sharedSplitMethod === 'custom' && (
{trip.members.map(m => ( ))}
)}
); })}
合計 ¥{editedTotal.toLocaleString()}
); })()} {/* ── PERSONAL: no extra UI needed ── */} {/* ── SHARED mode ── */} {scope === 'shared' && ( <> {/* 誰先付 */}
誰先付
{trip.members.map(m => ( ))}
{/* Split method toggle */}
分攤方式
{[['equal', '均分'], ['custom', '個別分配']].map(([v, label]) => ( ))}
{/* Member summary / settled status */}
{sharedSplitMethod === 'equal' ? '費用分配(均分)' : '費用分配'}
{(() => { const editedTotal = editableItems.reduce((s, it) => s + (parseFloat(it.subtotal) || 0), 0); const perMember = trip.members.length > 0 ? editedTotal / trip.members.length : 0; let anyShown = false; const rows = trip.members.map(m => { const amt = sharedSplitMethod === 'equal' ? perMember : editableItems.reduce((s, it, i) => itemAssignments[i] === m.id ? s + (parseFloat(it.subtotal) || 0) : s, 0); if (sharedSplitMethod === 'custom' && amt === 0) return null; anyShown = true; const settled = !!memberSettled[m.id]; return (
{(m.display_name || '?').slice(0, 1)}
{m.display_name}
¥{Math.round(amt).toLocaleString()}
); }); if (!anyShown && sharedSplitMethod === 'custom') { return
請在明細中為每項選擇負責人
; } return rows; })()}
)} )} {/* Prompt when in home mode and no trip selected yet */} {trips && !propTrip && !trip && (
請選擇旅程以設定分類和支出類型
)} )}
); } // ── Receipt Detail / Edit Sheet ─────────────────────────────── function ReceiptDetailSheet({ receipt, onClose, onSaved, onDeleted }) { const [editing, setEditing] = React.useState(false); const [storeName, setStoreName] = React.useState(receipt.store_name || ''); const [totalAmt, setTotalAmt] = React.useState(String(receipt.total_amount || '')); const [receiptDate, setDate] = React.useState( receipt.receipt_date ? receipt.receipt_date.slice(0, 10) : '' ); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(''); const inputStyle = { width: '100%', padding: '10px 12px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg, fontSize: 16, color: TABI_COLORS.ink, outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }; async function save() { setSaving(true); try { const r = await fetch(`/api/receipts/${receipt.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify({ store_name: storeName || null, total_amount: parseInt(totalAmt, 10) || null, receipt_date: receiptDate || null, }), }); if (!r.ok) throw new Error(await r.text()); onSaved(); } catch (e) { setError('儲存失敗:' + e.message); setSaving(false); } } async function del() { if (!confirm('確定要刪除這張發票?')) return; try { await fetch(`/api/receipts/${receipt.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${window.tabiToken()}` }, }); onDeleted(); } catch (e) { setError('刪除失敗:' + e.message); } } const action = editing ? ( <> {error &&
{error}
}
) : ( ); return ( {/* Original photo */} {receipt.image_url && (
原始收據
)} {!editing ? ( /* View mode — styled receipt card */
{/* Header */}
{receipt.store_name || '未知店家'}
{receipt.receipt_date && (
{receipt.receipt_date}
)}
{/* Items */} {(receipt.items || []).length > 0 && (
{receipt.items.map((item, i) => { const showOrig = item.original_name && item.translated_name && item.original_name !== item.translated_name; return (
{item.translated_name || item.original_name} {item.qty > 1 && ×{item.qty}}
{showOrig && (
{item.original_name}
)}
¥{item.subtotal?.toLocaleString?.() ?? item.subtotal}
); })}
)} {/* Total row */}
0 ? 4 : 0, }}> 合計 ¥{(receipt.total_amount || 0).toLocaleString()}
) : ( /* Edit mode */ <>
店家名稱
setStoreName(e.target.value)} placeholder="店家名稱" style={inputStyle} />
日期
setDate(e.target.value)} style={inputStyle} />
合計(JPY)
setTotalAmt(e.target.value)} placeholder="0" style={inputStyle} inputMode="numeric" />
)}
); } // ── Trips List Screen ───────────────────────────────────────── function TripsScreen({ go, onOpen }) { const [trips, setTrips] = React.useState([]); const [loading, setLoading] = React.useState(true); const [sheet, setSheet] = React.useState(null); // 'create' | 'join' async function load() { setLoading(true); try { const r = await fetch('/api/trips', { headers: { Authorization: `Bearer ${window.tabiToken()}` } }); if (r.ok) setTrips(await r.json()); } finally { setLoading(false); } } React.useEffect(() => { load(); }, []); return (
go('home')} right={
} />
{loading && (
載入中…
)} {!loading && trips.length === 0 && (
✈️
尚無旅行紀錄
新增第一筆旅行,開始記帳吧!
)} {trips.map(t => (
onOpen(t.id)} style={{ background: TABI_COLORS.card, borderRadius: 18, padding: '16px', marginTop: 12, border: `1px solid ${TABI_COLORS.line}`, cursor: 'pointer', }}>
✈️
{t.name}
{t.destination ? `${t.destination} · ` : ''} {t.start_date ? `${_fmtDateFull(t.start_date)}${t.end_date ? ' — ' + _fmtDateFull(t.end_date) : ''}` : '尚未設定日期'}
{t.member_count} 人 · {t.expense_count} 筆支出
合計
{_fmtAmt(t.total_amount, t.currency)}
))}
{sheet === 'create' && ( setSheet(null)} onCreated={tripId => { setSheet(null); load(); onOpen(tripId); }} /> )} {sheet === 'join' && ( setSheet(null)} onJoined={(tripId) => { setSheet(null); load(); onOpen(tripId); }} /> )}
); } // ── Expense Card ───────────────────────────────────────────── function ExpenseCard({ e, trip, startDate, membersMap, includeProxy, onTap }) { const payer = membersMap[e.paid_by]; const isPreTrip = startDate && new Date(e.expense_date) < startDate; const d = new Date(e.expense_date); const dateLabel = `${d.getMonth() + 1}/${d.getDate()}`; return (
{/* Category icon */}
{e.icon || CAT_ICONS[e.category] || '💳'}
{/* Content */}
{e.title}
{e.is_proxy && ( 代購 )} {isPreTrip && ( 事前 )}
{payer && ( {payer.initial} )} {payer?.display_name} · {e.category || '其他'} · {dateLabel} {e.is_proxy && e.proxy_for && ( <>{e.proxy_for} )}
{/* Amount */}
{_fmtAmt(e.amount, e.currency)}
{e.split_method === 'equal' ? `÷${trip.members.length}` : e.split_method === 'solo' ? '僅我' : '自定義'}
); } // ── Date filter helpers ─────────────────────────────────────── function _filterExpensesByDate(expenses, dateFilter, startDate) { if (dateFilter === 'all') return expenses; if (dateFilter === 'pre') return expenses.filter(e => startDate && new Date(e.expense_date) < startDate); return expenses.filter(e => e.expense_date && e.expense_date.slice(0, 10) === dateFilter); } function _filterReceiptsByDate(receipts, dateFilter, startDate) { if (dateFilter === 'all') return receipts; const getDs = r => r.receipt_date || (r.created_at && r.created_at.slice(0, 10)) || ''; if (dateFilter === 'pre') return receipts.filter(r => { const ds = getDs(r); return ds && startDate && new Date(ds) < startDate; }); return receipts.filter(r => getDs(r) === dateFilter); } // ── Date filter bar ─────────────────────────────────────────── function DateFilterBar({ trip, startDate, expenses, receipts, dateFilter, onSelect }) { // Build list of dates from trip range; fall back to expense dates const dates = React.useMemo(() => { const set = new Set(); if (trip.start_date && trip.end_date) { const end = new Date(trip.end_date); for (let d = new Date(trip.start_date); d <= end; d.setDate(d.getDate() + 1)) { set.add(d.toISOString().slice(0, 10)); } } // Also include any in-trip expense/receipt dates not already in the range (expenses || []).forEach(e => { if (e.expense_date) { const ds = e.expense_date.slice(0, 10); if (!startDate || new Date(ds) >= startDate) set.add(ds); } }); (receipts || []).forEach(r => { const ds = r.receipt_date || (r.created_at && r.created_at.slice(0, 10)); if (ds && (!startDate || new Date(ds) >= startDate)) set.add(ds); }); return Array.from(set).sort(); }, [trip.start_date, trip.end_date, expenses, receipts]); if (dates.length === 0 && !startDate) return null; const chips = [ { id: 'all', label: '全部' }, ...(startDate ? [{ id: 'pre', label: '事前' }] : []), ...dates.map(ds => { const d = new Date(ds + 'T00:00:00'); return { id: ds, label: `${d.getMonth()+1}/${d.getDate()}` }; }), ]; return (
{chips.map(chip => { const on = dateFilter === chip.id; return ( ); })}
); } // ── Settlement Sheet ────────────────────────────────────────── function SettlementSheet({ transfers, membersMap, rate, onClose, onSettleAll }) { const [confirmSettle, setConfirmSettle] = React.useState(false); const [settling, setSettling] = React.useState(false); return (
✨ 已用最簡轉帳算出 {transfers.length} 筆結帳(共同支出)
{transfers.length === 0 ? (
所有人都結清了!
) : transfers.map((t, i) => { const from = membersMap[t.from]; const to = membersMap[t.to]; if (!from || !to) return null; return (
{from.display_name} {to.display_name}
¥{t.amount.toLocaleString()} {rate && ≈ NT${Math.round(t.amount * rate).toLocaleString()}}
); })} {transfers.length > 0 && onSettleAll && ( )} {confirmSettle && ReactDOM.createPortal(
setConfirmSettle(false)} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 9100, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, }}>
e.stopPropagation()} style={{ background: TABI_COLORS.bg, borderRadius: 20, padding: '24px 22px', width: '100%', maxWidth: 300, }}>
確認全部結清?
所有共同支出將標記為已結算,此操作無法還原。請確認大家都已完成轉帳再繼續。
, document.body )}
); } // ── Proxy Settlement Sheet ─────────────────────────────────── function ProxySheet({ trip, onClose, onSaved }) { const [selected, setSelected] = React.useState(null); const [customAmt, setCustomAmt] = React.useState(''); const [saving, setSaving] = React.useState(false); const membersMap = {}; trip.members.forEach(m => membersMap[m.id] = m); const rate = trip.jpy_twd_rate; // All proxy expenses; remaining = amount - settled_amount const allProxy = trip.expenses.filter(e => e.is_proxy); const eRemaining = e => e.amount - (e.settled_amount || 0); // Group by proxy_for; include both pending and fully settled for history const groups = {}; allProxy.forEach(e => { const k = e.proxy_for || '未指定'; if (!groups[k]) groups[k] = { name: k, pending: 0, total: 0, paidBack: 0, expenses: [] }; groups[k].total += e.amount; groups[k].paidBack += (e.settled_amount || 0); groups[k].pending += eRemaining(e); groups[k].expenses.push(e); }); const groupList = Object.values(groups) .filter(g => g.pending > 0) .sort((a, b) => b.pending - a.pending); const settledGroups = Object.values(groups).filter(g => g.pending === 0 && g.total > 0); const selectedGroup = selected ? groups[selected] : null; // Cascade payment across multiple expenses (sorted by remaining desc so smaller ones clear first) async function settleCustomCascade(expenses, totalPay) { setSaving(true); let left = totalPay; const sorted = [...expenses].sort((a, b) => eRemaining(a) - eRemaining(b)); // clear small first for (const e of sorted) { if (left <= 0) break; const rem = eRemaining(e); if (rem <= 0) continue; const pay = Math.min(left, rem); await fetch(`/api/trips/${trip.id}/expenses/${e.id}/proxy-settle`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify(pay >= rem ? {} : { amount: pay }), }); left -= pay; } setSaving(false); onSaved(); } async function settleOne(expenseId) { setSaving(true); await fetch(`/api/trips/${trip.id}/expenses/${expenseId}/proxy-settle`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify({}), }); setSaving(false); onSaved(); } return ( { setSelected(null); setCustomAmt(''); } : onClose}> {!selected ? ( <> {groupList.length === 0 && settledGroups.length === 0 && (
🎁
尚無代購紀錄
)} {/* Pending groups */} {groupList.map(g => (
setSelected(g.name)} style={{ background: TABI_COLORS.card, borderRadius: 14, padding: '12px 14px', marginBottom: 10, border: `1px solid ${TABI_COLORS.line}`, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12, }}>
🎁
{g.name}
共 {_fmtAmt(g.total, trip.currency)} {g.paidBack > 0 && · 已還 {_fmtAmt(g.paidBack, trip.currency)}}
{_fmtAmt(g.pending, trip.currency)}
{rate &&
≈ NT${Math.round(g.pending * rate).toLocaleString()}
}
))} {/* Settled groups */} {settledGroups.length > 0 && (
已結清
{settledGroups.map(g => (
{g.name}
{g.expenses.length} 筆
{_fmtAmt(g.total, trip.currency)}
))}
)} ) : ( <> {/* Summary card */} {(() => { const pending = selectedGroup.pending; const paidBack = selectedGroup.paidBack; const total = selectedGroup.total; return (
待還
{_fmtAmt(pending, trip.currency)}
{rate &&
≈ NT${Math.round(pending * rate).toLocaleString()}
}
{paidBack > 0 && (
已還
{_fmtAmt(paidBack, trip.currency)}
共 {_fmtAmt(total, trip.currency)}
)}
); })()} {/* Per-expense rows */} {selectedGroup.expenses.map(e => { const rem = eRemaining(e); const paid = e.settled_amount || 0; const payer = membersMap[e.paid_by]; const done = rem <= 0; return (
{done ? '✓' : (CAT_ICONS[e.category] || '🎁')}
{e.title}
{payer?.display_name} 墊付 {paid > 0 && !done && · 已還 {_fmtAmt(paid, trip.currency)}}
{done ? _fmtAmt(e.amount, trip.currency) : _fmtAmt(rem, trip.currency)}
{!done && ( )}
); })} {/* Custom amount */} {selectedGroup.pending > 0 && (
自訂還款金額
setCustomAmt(e.target.value)} placeholder="輸入金額" inputMode="numeric" style={{ flex: 1, padding: '10px 12px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line}`, background: TABI_COLORS.bg, fontSize: 16, color: TABI_COLORS.ink, outline: 'none', }} />
)} {/* Settle all */} {selectedGroup.pending > 0 && ( )} )}
); } // ── Trip Invite Sheet ───────────────────────────────────────── function TripInviteSheet({ trip, inviteCode, inviteExpiry, onClose, onRegenerate }) { const [copied, setCopied] = React.useState(false); const [regenerating, setRegenerating] = React.useState(false); const code = inviteCode || trip.invite_code; const url = `https://tabi.sungcheng.org/?join=${code}`; const expiresAt = inviteExpiry || trip.invite_expires_at; const isExpired = expiresAt && new Date(expiresAt) <= new Date(); let expiryDateText = ''; let expiryLabel = ''; if (expiresAt) { const d = new Date(expiresAt); expiryDateText = `${d.getFullYear()}/${String(d.getMonth()+1).padStart(2,'0')}/${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')} 到期`; const diffDays = Math.ceil((d - new Date()) / 86400000); if (isExpired) expiryLabel = '已過期'; else if (diffDays === 1) expiryLabel = '明天到期'; else expiryLabel = `${diffDays} 天後到期`; } async function copyLink() { await navigator.clipboard?.writeText(url); setCopied(true); setTimeout(() => setCopied(false), 2000); } function shareLink() { navigator.share?.({ title: `加入 ${trip.name}`, url }); } async function handleRegenerate() { setRegenerating(true); await onRegenerate(); setRegenerating(false); } return (
將此連結傳給要共同記帳的好友即可加入,或在記帳頁面點擊「加入」並輸入邀請碼。
{isExpired && (
邀請連結已過期,請重新生成。
)}
邀請連結
{expiresAt && (
{expiryLabel} {expiryDateText}
)}
{url}
邀請碼
{code}
在記帳頁面點「加入」輸入此碼
{typeof navigator !== 'undefined' && navigator.share && ( )}
); } // ── Trip Detail Screen ──────────────────────────────────────── function TripDetailScreen({ go, tripId, onBack, user }) { const [trip, setTrip] = React.useState(null); const [tab, setTab] = React.useState('overview'); const [sheet, setSheet] = React.useState(null); // 'expense' | 'receipt' | 'invite' const [includeProxy, setProxy] = React.useState(true); const [receipts, setReceipts] = React.useState([]); const [loadingR, setLoadingR] = React.useState(false); const [inviteCode, setInviteCode] = React.useState(null); const [inviteExpiry, setInviteExpiry] = React.useState(null); const [editingExpense, setEditingExpense] = React.useState(null); const [viewingReceipt, setViewingReceipt] = React.useState(null); const [dateFilter, setDateFilter] = React.useState('all'); const [showSettle, setShowSettle] = React.useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); const [deleting, setDeleting] = React.useState(false); const [statDay, setStatDay] = React.useState(null); const [statCat, setStatCat] = React.useState(null); const [showProxy, setShowProxy] = React.useState(false); const [showMemberManage, setShowMemberManage] = React.useState(false); const [showPreTrip, setShowPreTrip] = React.useState(false); async function loadTrip() { const r = await fetch(`/api/trips/${tripId}`, { headers: { Authorization: `Bearer ${window.tabiToken()}` }, }); if (r.ok) setTrip(await r.json()); else go('trips'); } async function loadReceipts(showSpinner = false) { if (showSpinner) setLoadingR(true); const r = await fetch(`/api/trips/${tripId}/receipts`, { headers: { Authorization: `Bearer ${window.tabiToken()}` }, }); if (r.ok) setReceipts(await r.json()); setLoadingR(false); } React.useEffect(() => { loadTrip(); const timer = setInterval(() => { if (!document.hidden) loadTrip(); }, 8000); return () => clearInterval(timer); }, [tripId]); React.useEffect(() => { if (tab !== 'receipts') return; loadReceipts(true); const timer = setInterval(() => { if (!document.hidden) loadReceipts(false); }, 8000); return () => clearInterval(timer); }, [tab]); async function generateInvite() { const r = await fetch(`/api/trips/${tripId}/invite`, { method: 'POST', headers: { Authorization: `Bearer ${window.tabiToken()}` }, }); if (r.ok) { const d = await r.json(); setInviteCode(d.invite_code); setInviteExpiry(d.expires_at); } } async function openInvite() { if (!inviteCode && !trip.invite_code) await generateInvite(); setSheet('invite'); } async function deleteTrip() { setDeleting(true); const r = await fetch(`/api/trips/${tripId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${window.tabiToken()}` }, }); if (r.ok || r.status === 204) { go('trips'); } else { setDeleting(false); setShowDeleteConfirm(false); } } if (!trip) return (
載入中…
); const sharedExpenses = trip.expenses.filter(e => e.visibility === 'shared'); const personalExpenses = trip.expenses.filter(e => e.visibility === 'personal'); const visibleExpenses = includeProxy ? trip.expenses : trip.expenses.filter(e => !e.is_proxy); const rate = trip.jpy_twd_rate; // Current user's TripMember — used to look up split portion const myMember = user ? trip.members.find(m => m.user_id === user.id) : null; // For shared expenses return only this user's split; personal expenses are already theirs const myShare = (e) => { if (e.visibility === 'personal') return e.amount; if (!myMember) return e.amount; const split = (e.splits || []).find(s => s.member_id === myMember.id); return split ? split.amount : 0; }; const total = visibleExpenses.reduce((s, e) => s + myShare(e), 0); const startDate = trip.start_date ? new Date(trip.start_date) : null; const preTripAmt = visibleExpenses.filter(e => startDate && new Date(e.expense_date) < startDate).reduce((s, e) => s + myShare(e), 0); const inTripAmt = visibleExpenses.filter(e => !startDate || new Date(e.expense_date) >= startDate).reduce((s, e) => s + myShare(e), 0); const proxyExpsAll = trip.expenses.filter(e => e.is_proxy); const proxyTotal = proxyExpsAll.reduce((s, e) => s + e.amount, 0); const proxySettled = proxyExpsAll.reduce((s, e) => s + (e.settled_amount || 0), 0); const membersMap = {}; trip.members.forEach(m => membersMap[m.id] = m); const { balances, transfers } = computeSettlement(trip.members, sharedExpenses); // ── Daily stats for Overview ────────────────────────────── const tripDays = (() => { if (!trip.start_date || !trip.end_date) return []; const days = []; const end = new Date(trip.end_date + 'T12:00:00'); const d = new Date(trip.start_date + 'T12:00:00'); while (d <= end) { days.push(d.toISOString().substring(0, 10)); d.setDate(d.getDate() + 1); } return days; })(); const dailyMap = (() => { const map = {}; visibleExpenses.forEach(e => { const day = e.expense_date.substring(0, 10); if (!map[day]) map[day] = { total: 0, expenses: [] }; map[day].total += myShare(e); map[day].expenses.push(e); }); return map; })(); const allExpDays = Object.keys(dailyMap).sort(); const chartDays = showPreTrip ? allExpDays : (tripDays.length > 0 ? tripDays : allExpDays); const maxDayAmt = Math.max(1, ...chartDays.map(d => dailyMap[d]?.total || 0)); const hasPreTripDays = startDate && allExpDays.some(d => new Date(d + 'T12:00:00') < startDate); const statSrcExp = statDay ? (dailyMap[statDay]?.expenses || []) : visibleExpenses; const catTotals = (() => { const cats = {}; statSrcExp.forEach(e => { const c = e.category || '其他'; cats[c] = (cats[c] || 0) + myShare(e); }); return Object.entries(cats).sort((a, b) => b[1] - a[1]); })(); const catMax = Math.max(1, ...catTotals.map(([, v]) => v)); const statSrcTotal = statSrcExp.reduce((s, e) => s + myShare(e), 0); const overviewExps = statSrcExp .filter(e => !statCat || (e.category || '其他') === statCat) .sort((a, b) => new Date(b.expense_date) - new Date(a.expense_date)); const tripDuration = trip.start_date && trip.end_date ? Math.ceil((new Date(trip.end_date) - new Date(trip.start_date)) / 86400000) + 1 : null; // ── Overview-specific computations ────────────────────────── const memberPaid = {}, memberShare = {}; trip.members.forEach(m => { memberPaid[m.id] = 0; memberShare[m.id] = 0; }); sharedExpenses.forEach(e => { if (e.paid_by) memberPaid[e.paid_by] = (memberPaid[e.paid_by] || 0) + e.amount; (e.splits || []).forEach(s => { memberShare[s.member_id] = (memberShare[s.member_id] || 0) + s.amount; }); }); const maxMemberAmt = Math.max(1, ...trip.members.map(m => Math.max(memberPaid[m.id] || 0, memberShare[m.id] || 0))); const avgDaily = tripDuration ? Math.round(inTripAmt / tripDuration) : null; const perPerson = trip.members.length > 1 ? Math.round(total / trip.members.length) : null; const isEnded = !!(trip.end_date && new Date(trip.end_date + 'T23:59:59') < new Date()); const PALETTE = [TABI_COLORS.red, TABI_COLORS.blue, TABI_COLORS.green, TABI_COLORS.warn, '#9B59B6', '#1ABC9C']; // Sort expenses newest-first, then apply date filter const sortedPersonal = [...personalExpenses].sort((a, b) => new Date(b.expense_date) - new Date(a.expense_date)); const sortedShared = [...sharedExpenses].sort((a, b) => new Date(b.expense_date) - new Date(a.expense_date)); const sortedReceipts = [...receipts].sort((a, b) => new Date(b.receipt_date || b.created_at) - new Date(a.receipt_date || a.created_at)); const filteredPersonal = _filterExpensesByDate(sortedPersonal, dateFilter, startDate); const filteredShared = _filterExpensesByDate(sortedShared, dateFilter, startDate); const filteredReceipts = _filterReceiptsByDate(sortedReceipts, dateFilter, startDate); const TABS = [ { id: 'overview', label: '概覽' }, { id: 'expenses_personal', label: '個人支出', count: personalExpenses.length }, { id: 'expenses_shared', label: '共同支出', count: sharedExpenses.length }, { id: 'receipts', label: '發票', count: receipts.length }, ]; return (
go('trips'))} right={ } /> {/* Tab bar */}
{TABS.map(t => { const on = tab === t.id; return ( ); })}
{/* ── Overview Tab ── */} {tab === 'overview' && (
{/* ── Quick Stats Row ── */}
{[ { label: '總花費', value: _fmtAmt(total, trip.currency), sub: rate ? `≈ NT$${Math.round(total * rate / 1000)}k` : null }, { label: '日均花費', value: avgDaily != null ? _fmtAmt(avgDaily, trip.currency) : `${trip.expenses.length}筆`, sub: avgDaily != null ? '旅途中' : '筆數' }, { label: tripDuration ? '旅行天數' : '成員人數', value: tripDuration ? `${tripDuration}天` : `${trip.members.length}人`, sub: null }, ].map((s, i) => (
{s.label}
{s.value}
{s.sub &&
{s.sub}
}
))}
{/* ── Settlement Banner ── */} {sharedExpenses.length > 0 && (
setShowSettle(true)} style={{ marginBottom: 12, padding: '12px 14px', borderRadius: 14, cursor: 'pointer', background: transfers.length === 0 ? `${TABI_COLORS.green}12` : `${TABI_COLORS.warn}20`, border: `1px solid ${transfers.length === 0 ? TABI_COLORS.green + '35' : TABI_COLORS.warn + '60'}`, display: 'flex', alignItems: 'center', gap: 10, }}> {transfers.length === 0 ? '✅' : '🤝'}
{transfers.length === 0 ? '所有人都已結清 ✓' : `需 ${transfers.length} 筆轉帳完成結算`}
{transfers.length > 0 && (
{transfers.map(t => `${membersMap[t.from]?.display_name} → ${membersMap[t.to]?.display_name} ${_fmtAmt(t.amount, trip.currency)}`).join(' ')}
)}
)} {/* ── Hero Card ── */}
{trip.start_date && (
📅 {_fmtDateFull(trip.start_date)} {trip.end_date && <>{_fmtDateFull(trip.end_date)}} {tripDuration && {tripDuration}天}
)}
我的支出
{_fmtAmt(total, trip.currency)}
{rate &&
≈ NT${Math.round(total * rate).toLocaleString()}
}
含代購
setProxy(!includeProxy)} style={{ width: 44, height: 24, borderRadius: 12, cursor: 'pointer', background: includeProxy ? '#7BC97B' : 'rgba(255,255,255,0.2)', position: 'relative', transition: 'background 0.2s', marginLeft: 'auto', }}>
事前支出
{_fmtAmt(preTripAmt, trip.currency)}
機票・住宿・門票等
旅途中支出
{_fmtAmt(inTripAmt, trip.currency)}
餐飲・交通・購物
{proxyTotal > 0 && <>
setShowProxy(true)}>
代購金額 →
{_fmtAmt(proxyTotal, trip.currency)}
{proxySettled > 0 && (
已還 {_fmtAmt(proxySettled, trip.currency)} {proxySettled < proxyTotal && · 餘 {_fmtAmt(proxyTotal - proxySettled, trip.currency)}}
)} {proxySettled === 0 &&
點擊查看結算
}
}
{/* ── Member Spending Comparison ── */} {trip.members.length > 1 && sharedExpenses.length > 0 && (
成員支出比較
{trip.members.map(m => { const paid = memberPaid[m.id] || 0; const share = memberShare[m.id] || 0; const bal = balances[m.id] || 0; const paidW = Math.round(paid / maxMemberAmt * 100); const shareW = Math.round(share / maxMemberAmt * 100); return (
{(m.display_name || '?')[0]}
{m.display_name}
實付 {_fmtAmt(paid, trip.currency)}
= 0 ? TABI_COLORS.green : TABI_COLORS.red, }}>{bal >= 0 ? '+' : ''}{_fmtAmt(Math.abs(Math.round(bal)), trip.currency)}
{/* Share bar (grey bg = should pay) overlaid with paid bar (color fg = actually paid) */}
應付 {_fmtAmt(share, trip.currency)}
= 0 ? TABI_COLORS.green : TABI_COLORS.red, fontWeight: 600 }}> {bal >= 0 ? '被欠款' : '欠款'}
); })} {transfers.length > 0 && (
結算方案
{transfers.map((t, i) => (
{(membersMap[t.from]?.display_name || '?')[0]}
{membersMap[t.from]?.display_name}
{(membersMap[t.to]?.display_name || '?')[0]}
{membersMap[t.to]?.display_name}
{_fmtAmt(t.amount, trip.currency)}
))}
)}
)} {/* ── Donut + Category Analysis ── */} {catTotals.length > 0 && (() => { let cumPct = 0; const segments = catTotals.slice(0, 6).map(([cat, amt]) => { const pct = statSrcTotal > 0 ? amt / statSrcTotal * 100 : 0; const seg = { cat, amt, pct, start: cumPct }; cumPct += pct; return seg; }); const gradient = segments.map((s, i) => `${PALETTE[i % PALETTE.length]} ${s.start.toFixed(1)}% ${(s.start + s.pct).toFixed(1)}%`).join(', '); return (
類別分析{statDay ? ` · ${(() => { const d = new Date(statDay + 'T12:00:00'); return `${d.getMonth()+1}/${d.getDate()}`; })()}` : ''}
{(statDay || statCat) && }
{/* Donut */}
合計
{_fmtAmt(statSrcTotal, trip.currency)}
{/* Legend */}
{segments.map(({ cat, amt, pct }, i) => { const on = statCat === cat; return (
setStatCat(on ? null : cat)} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, padding: '3px 6px', borderRadius: 7, cursor: 'pointer', background: on ? `${PALETTE[i % PALETTE.length]}18` : 'transparent', }}>
{CAT_ICONS[cat] || ''} {cat} {_fmtAmt(amt, trip.currency)} {Math.round(pct)}%
); })}
); })()} {/* ── Daily Bar Chart ── */} {chartDays.length > 0 && (
每日支出
{hasPreTripDays && ( )} {(statDay || statCat) && ( )}
{chartDays.map(day => { const amt = dailyMap[day]?.total || 0; const barH = amt > 0 ? Math.max(6, Math.round((amt / maxDayAmt) * 52)) : 2; const on = statDay === day; const isPreTrip = startDate && new Date(day + 'T12:00:00') < startDate; const d = new Date(day + 'T12:00:00'); const barColor = on ? TABI_COLORS.red : isPreTrip ? TABI_COLORS.blue : amt > 0 ? `${TABI_COLORS.ink}35` : TABI_COLORS.line; return ( ); })}
{showPreTrip && hasPreTripDays && (
事前支出
旅途中
)} {statDay && (
{(() => { const d = new Date(statDay + 'T12:00:00'); return `${d.getMonth()+1}月${d.getDate()}日`; })()}
{_fmtAmt(dailyMap[statDay]?.total || 0, trip.currency)}
{rate &&
≈ NT${Math.round((dailyMap[statDay]?.total || 0) * rate).toLocaleString()}
}
)}
)} {/* ── Filtered Expense List ── */} {(statDay || statCat) && overviewExps.length > 0 && (
支出明細 {statCat && {CAT_ICONS[statCat] || ''} {statCat}}
{overviewExps.map(e => ( setEditingExpense(e)} /> ))}
)} {/* ── Trip Recap (trip ended) ── */} {isEnded && trip.expenses.length > 0 && (
旅程回顧 · TRIP RECAP
旅行總花費
{_fmtAmt(total, trip.currency)}
{rate &&
≈ NT${Math.round(total * rate).toLocaleString()}
}
每日平均
{avgDaily != null ? _fmtAmt(avgDaily, trip.currency) : '—'}
{avgDaily && rate &&
≈ NT${Math.round(avgDaily * rate).toLocaleString()}
}
{catTotals[0] &&
最多支出類別
{CAT_ICONS[catTotals[0][0]] || ''} {catTotals[0][0]}
{_fmtAmt(catTotals[0][1], trip.currency)}
} {chartDays.length > 0 && (() => { const topDay = chartDays.reduce((best, d) => (dailyMap[d]?.total || 0) > (dailyMap[best]?.total || 0) ? d : best, chartDays[0]); const topD = new Date(topDay + 'T12:00:00'); return
最高消費日
{topD.getMonth()+1}/{topD.getDate()}
{_fmtAmt(dailyMap[topDay]?.total || 0, trip.currency)}
; })()}
)} {/* ── Delete trip — owner only ── */} {trip.is_owner && (
{!showDeleteConfirm ? ( ) : (
確定要刪除「{trip.name}」?
所有支出與發票記錄將一併刪除,且無法復原。
)}
)}
)} {/* ── 支出(個人) Tab ── */} {tab === 'expenses_personal' && ( <>
{filteredPersonal.length === 0 ? (
💳
{dateFilter === 'all' ? '尚無個人支出' : '此日期沒有支出'}
點擊右下角 + 新增支出
) : (() => { const groups = {}; filteredPersonal.forEach(e => { const day = e.expense_date.substring(0, 10); if (!groups[day]) groups[day] = []; groups[day].push(e); }); const days = Object.keys(groups).sort().reverse(); const showHeaders = dateFilter === 'all' || dateFilter === 'pre'; return days.map(day => { const d = new Date(day + 'T12:00:00'); const dayLabel = d.getMonth() + 1 + '月' + d.getDate() + '日'; const dayTotal = groups[day].reduce((s, e) => s + myShare(e), 0); return (
{showHeaders && (
{dayLabel}
{_fmtAmt(dayTotal, trip.currency)}
)} {groups[day].map(e => setEditingExpense(e)} />)}
); }); })()}
)} {/* ── 支出(共同) Tab ── */} {tab === 'expenses_shared' && ( <>
{/* Settlement button */} {filteredShared.length === 0 ? (
💳
{dateFilter === 'all' ? '尚無共同支出' : '此日期沒有支出'}
點擊右下角 + 新增支出
) : (() => { const groups = {}; filteredShared.forEach(e => { const day = e.expense_date.substring(0, 10); if (!groups[day]) groups[day] = []; groups[day].push(e); }); const days = Object.keys(groups).sort().reverse(); const showHeaders = dateFilter === 'all' || dateFilter === 'pre'; return days.map(day => { const d = new Date(day + 'T12:00:00'); const dayLabel = d.getMonth() + 1 + '月' + d.getDate() + '日'; const dayTotal = groups[day].reduce((s, e) => s + e.amount, 0); return (
{showHeaders && (
{dayLabel}
{_fmtAmt(dayTotal, trip.currency)}
)} {groups[day].map(e => setEditingExpense(e)} />)}
); }); })()}
)} {/* ── Receipts Tab ── */} {tab === 'receipts' && ( <>
{loadingR &&
載入中…
} {!loadingR && filteredReceipts.length === 0 && (
🧾
{dateFilter === 'all' ? '尚無發票紀錄' : '此日期沒有發票'}
點擊上方按鈕掃描發票
)} {filteredReceipts.map(r => (
setViewingReceipt(r)} style={{ background: TABI_COLORS.card, borderRadius: 18, padding: '14px 16px', marginTop: 10, cursor: 'pointer', boxShadow: '0 2px 14px rgba(26,22,18,0.07)', display: 'flex', alignItems: 'center', gap: 14, }}>
🧾
{r.store_name || '未知店家'}
{r.receipt_date || _fmtDateFull(r.created_at)} {r.scope === 'shared' ? '共同' : '個人'}
{r.total_amount != null && (
¥{r.total_amount.toLocaleString()}
)}
))}
)} {/* FAB — add expense, clears tab bar (≈57px) + safe area */} {(tab === 'expenses_personal' || tab === 'expenses_shared' || tab === 'overview') && ( )} {sheet === 'invite' && ( setSheet(null)} onRegenerate={generateInvite} /> )} {sheet === 'expense' && ( setSheet(null)} onSaved={() => { setSheet(null); loadTrip(); }} /> )} {sheet === 'receipt' && ( setSheet(null)} onSaved={() => { setSheet(null); loadTrip(); loadReceipts(); }} /> )} {editingExpense && ( setEditingExpense(null)} onSaved={() => { setEditingExpense(null); loadTrip(); }} onDeleted={() => { setEditingExpense(null); loadTrip(); }} /> )} {viewingReceipt && ( setViewingReceipt(null)} onSaved={() => { setViewingReceipt(null); loadReceipts(); }} onDeleted={() => { setViewingReceipt(null); loadReceipts(); }} /> )} {showSettle && ( setShowSettle(false)} onSettleAll={async () => { await fetch(`/api/trips/${trip.id}/settle-all`, { method: 'POST', headers: { Authorization: `Bearer ${window.tabiToken()}` }, }); setShowSettle(false); loadTrip(); }} /> )} {showProxy && ( setShowProxy(false)} onSaved={() => { setShowProxy(false); loadTrip(); }} /> )} {showMemberManage && ( setShowMemberManage(false)} onSaved={() => { loadTrip(); }} /> )}
); } // ─────────── MEMBER MANAGE SHEET ─────────── function MemberManageSheet({ trip, onClose, onSaved }) { const [editing, setEditing] = React.useState(null); const [editName, setEditName] = React.useState(''); const [editColor, setEditColor] = React.useState(''); const [saving, setSaving] = React.useState(false); const [addName, setAddName] = React.useState(''); const [addColor, setAddColor] = React.useState(MEMBER_PALETTE[0]); const [adding, setAdding] = React.useState(false); const [error, setError] = React.useState(''); const [confirmDel, setConfirmDel] = React.useState(null); // member object const [deleting, setDeleting] = React.useState(false); const [delError, setDelError] = React.useState(''); const openEdit = (m) => { setEditing(m.id); setEditName(m.display_name); setEditColor(m.color); setError(''); }; const saveEdit = async () => { if (!editName.trim()) return; setSaving(true); await fetch(`/api/trips/${trip.id}/members/${editing}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify({ display_name: editName.trim(), color: editColor }), }); setSaving(false); setEditing(null); onSaved(); }; const addMember = async () => { if (!addName.trim()) { setError('請輸入名稱'); return; } setAdding(true); setError(''); const r = await fetch(`/api/trips/${trip.id}/members`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.tabiToken()}` }, body: JSON.stringify({ display_name: addName.trim(), color: addColor }), }); setAdding(false); if (!r.ok) { setError('新增失敗,請再試一次'); return; } setAddName(''); setAddColor(MEMBER_PALETTE[0]); onSaved(); }; const removeMember = async () => { setDeleting(true); setDelError(''); const r = await fetch(`/api/trips/${trip.id}/members/${confirmDel.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${window.tabiToken()}` }, }); setDeleting(false); if (!r.ok) { const d = await r.json().catch(() => ({})); setDelError(d.detail || '移除失敗,請再試一次'); return; } setConfirmDel(null); onSaved(); }; return ReactDOM.createPortal(
{/* Handle */}
{/* Header */}
編輯成員
{trip.members.length} 位成員
{/* Member list */}
{trip.members.map(m => (
{editing === m.id ? (
名稱
setEditName(e.target.value)} style={{ width: '100%', padding: '9px 12px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line}`, fontSize: 16, color: TABI_COLORS.ink, background: TABI_COLORS.bg, outline: 'none', boxSizing: 'border-box' }} autoFocus />
顏色
{MEMBER_PALETTE.map(c => (
) : (
{m.display_name}
{m.role !== 'owner' && ( )}
)}
))}
{/* Add new member */}
新增成員
{ setAddName(e.target.value); setError(''); }} placeholder="成員名稱" style={{ width: '100%', padding: '9px 12px', borderRadius: 10, border: `1px solid ${TABI_COLORS.line}`, fontSize: 16, color: TABI_COLORS.ink, background: TABI_COLORS.bg, outline: 'none', boxSizing: 'border-box', marginBottom: 10 }} />
{MEMBER_PALETTE.map(c => (
{error &&
{error}
}
{/* Delete confirmation */} {confirmDel && ReactDOM.createPortal(
setConfirmDel(null)} style={{ position: 'fixed', inset: 0, zIndex: 9300, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24, }}>
e.stopPropagation()} style={{ background: TABI_COLORS.bg, borderRadius: 18, padding: '22px', width: '100%', maxWidth: 280, }}>
移除「{confirmDel.display_name}」?
移除後無法復原。若該成員有付款紀錄則無法移除。
{delError &&
{delError}
}
, document.body )}
, document.body ); } Object.assign(window, { computeSettlement, MemberAvatar, ExpenseCard, DateFilterBar, SettlementSheet, ProxySheet, TripsScreen, TripDetailScreen, AddExpenseSheet, EditExpenseSheet, CreateTripSheet, JoinTripSheet, ScanReceiptSheet, ReceiptDetailSheet, MemberManageSheet, });