// 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 */}
{/* 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 */}
{/* 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 && (
)}
{!isCustom && inputCurrency !== trip.currency && (
{fetchingRate ? '換算中…' : convertedAmt !== null ? `≈ ${trip.currency === 'JPY' ? '¥' : ''}${convertedAmt.toLocaleString()} ${trip.currency}` : '請輸入金額'}
)}
{/* Date */}
{/* 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 && (
)}
>
)}
{/* 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 */}
{/* Currency selector */}
支付幣別
{[trip.currency, 'TWD'].filter((v, i, a) => a.indexOf(v) === i).map(c => (
))}
{/* Amount — hidden when custom */}
{!isCustom && (
)}
{!isCustom && inputCurrency !== trip.currency && (
{fetchingRate ? '換算中…' : convertedAmt !== null ? `≈ ${trip.currency === 'JPY' ? '¥' : ''}${convertedAmt.toLocaleString()} ${trip.currency}` : '請輸入金額'}
)}
{/* Date */}
{/* 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 && (
)}
>
)}
{/* 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}
}
>
}>
旅行日期
幣別
{/* 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 */
<>
>
)}
);
}
// ── 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}
{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.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,
});