/**
* 交易详情页(plan.md「1.1 transaction/[id]」)。
*
* 从 ledger.transactions 按 id 查找,展示完整交易信息:
* - 日期 / 摘要 / 对方 / flag
* - 全部 postings(账户 / 金额 / 币种 / cost / price)
* - 标签 / 链接 / 元数据
* - 原始 .bean 文本
*/
import React, { useMemo, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Modal, TextInput, FlatList } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../../theme';
import { Card } from '../../components/Card';
import { useLedgerStore } from '../../store/ledgerStore';
import { useNumpadUiStore } from '../../store/numpadUiStore';
import { hash } from '../../domain/ledger';
import { useT } from '../../i18n';
import { TransactionCard } from '../../components/TransactionCard';
import { ScreenHeader } from '../../components/ScreenHeader';
import { logger } from '../../utils/logger';
export default function TransactionDetailScreen() {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const ledger = useLedgerStore(s => s.ledger);
const tx = useMemo(
() => ledger?.transactions.find(txn => txn.id === id) ?? null,
[ledger, id],
);
const [linkModalVisible, setLinkModalVisible] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const isEditable = tx ? tx.source.endsWith('main.bean') : false;
// 找出所有共享同一个链接标签的关联交易
const relatedTransactions = useMemo(() => {
if (!ledger || !tx || !tx.links || tx.links.length === 0) return [];
return ledger.transactions.filter(
t => t.id !== id && t.links.some(l => tx.links.includes(l))
);
}, [ledger, id, tx?.links]);
// 筛选可以用来关联的其他账单
const linkableTransactions = useMemo(() => {
if (!ledger) return [];
const list = ledger.transactions
.filter(t => t.id !== id && t.source.endsWith('main.bean') && !t.links.some(l => (tx?.links ?? []).includes(l)))
.filter(t => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
t.narration?.toLowerCase().includes(q) ||
t.payee?.toLowerCase().includes(q) ||
t.postings.some(p => p.account.toLowerCase().includes(q))
);
})
.sort((a, b) => b.date.localeCompare(a.date)); // 显式按交易日期【由新到旧 (最新 ➔ 最旧)】严格倒序排列
// 有搜索条件时展示最多 100 条匹配结果,无搜索词时默认展示最新 50 条
return searchQuery ? list.slice(0, 100) : list.slice(0, 50);
}, [ledger, id, tx?.links, searchQuery]);
const handleLinkTransaction = async (targetTx: typeof tx) => {
if (!tx || !targetTx) return;
let linkTag = tx.links[0] || targetTx.links[0] || '';
try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
// 取出已有链接标签,若没有则生成一个随机哈希标签
if (!linkTag) {
linkTag = 'lnk-' + Math.random().toString(36).slice(2, 8);
}
const addLinkToRaw = (raw: string, tag: string) => {
const lines = raw.split('\n');
const line = lines[0];
const commentIndex = line.indexOf(';');
if (line.includes(`^${tag}`)) {
return raw;
}
if (commentIndex !== -1) {
const beforeComment = line.substring(0, commentIndex).trimEnd();
const commentPart = line.substring(commentIndex);
lines[0] = `${beforeComment} ^${tag} ${commentPart}`;
} else {
lines[0] = line.trimEnd() + ` ^${tag}`;
}
return lines.join('\n');
};
const newCurrentRaw = addLinkToRaw(tx.raw, linkTag);
const newTargetRaw = addLinkToRaw(targetTx.raw, linkTag);
let newContent = mobileBean;
newContent = newContent.replaceAll(tx.raw, newCurrentRaw);
newContent = newContent.replaceAll(targetTx.raw, newTargetRaw);
const formatTxSummary = (t: typeof tx) => {
if (!t) return '';
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
};
await replaceMobileBean(newContent);
logger.info('ledgerStore', `关联交易成功: 标签 ^${linkTag}\n【交易 1】: ${formatTxSummary(tx)}\n【交易 2】: ${formatTxSummary(targetTx)}`, {
linkTag,
transaction1: {
id: tx.id,
date: tx.date,
payee: tx.payee,
narration: tx.narration,
postings: tx.postings,
},
transaction2: {
id: targetTx.id,
date: targetTx.date,
payee: targetTx.payee,
narration: targetTx.narration,
postings: targetTx.postings,
},
});
setLinkModalVisible(false);
// 解决 race condition:直接使用 hash() 算法计算新 ID 避免读取旧状态导致的 404 白屏
const newId = hash(`main.bean:${newCurrentRaw}`);
if (newId && newId !== id) {
router.replace(`/transaction/${newId}`);
}
Alert.alert(t('transaction.linkSuccess'), t('transaction.linkSuccessDesc', { tag: linkTag }));
} catch (e) {
logger.error('ledgerStore', `关联交易失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.linkFail'), String(e));
}
};
const handleUnlink = async (linkTag: string) => {
if (!tx) return;
try {
const { mobileBean, replaceMobileBean } = useLedgerStore.getState();
// 找出所有携带此 linkTag 且属于 main.bean 的同伴交易并同步清除,避免悬空标签
const companionTxs = (ledger?.transactions ?? []).filter(
t => t.links.includes(linkTag) && t.source.endsWith('main.bean')
);
let newContent = mobileBean;
let newCurrentRaw = tx.raw;
for (const companion of companionTxs) {
const lines = companion.raw.split('\n');
lines[0] = lines[0].replace(new RegExp(`\\s*\\^${linkTag}\\b`, 'g'), '');
const newRaw = lines.join('\n');
newContent = newContent.replaceAll(companion.raw, newRaw);
if (companion.id === tx.id) {
newCurrentRaw = newRaw;
}
}
const formatTxSummary = (t: typeof tx) => {
if (!t) return '';
const postingDesc = t.postings.map(p => `${p.account} (${p.amount ?? ''} ${p.currency ?? ''})`.trim()).join(', ');
return `[${t.date}] ${t.payee ? t.payee + ' - ' : ''}${t.narration} | 分录: [${postingDesc}] (ID: ${t.id})`;
};
await replaceMobileBean(newContent);
const unlinkedListStr = companionTxs.map(c => formatTxSummary(c)).join('\n ');
logger.info('ledgerStore', `解除交易关联成功: 标签 ^${linkTag} (同步解绑 ${companionTxs.length} 笔相关交易)\n 解绑交易列表:\n ${unlinkedListStr}`, {
linkTag,
unlinkedCount: companionTxs.length,
unlinkedTransactions: companionTxs.map(c => ({
id: c.id,
date: c.date,
payee: c.payee,
narration: c.narration,
postings: c.postings,
})),
});
// 解决 race condition:直接使用 hash() 算法计算新 ID
const newId = hash(`main.bean:${newCurrentRaw}`);
if (newId && newId !== id) {
router.replace(`/transaction/${newId}`);
}
Alert.alert(t('transaction.unlinkSuccess'), t('transaction.unlinkSuccessDesc', { tag: linkTag }));
} catch (e) {
logger.error('ledgerStore', `解除交易关联失败 (Tag: ^${linkTag})`, e);
Alert.alert(t('transaction.unlinkFail'), String(e));
}
};
if (!tx) {
return (
{t('transaction.notFoundDesc', { id })}
);
}
const isExpense = tx.postings.some(p => p.account.startsWith('Expenses'));
const isIncome = tx.postings.some(p => p.account.startsWith('Income'));
const directionLabel = isExpense ? t('transaction.directionExpense') : isIncome ? t('transaction.directionIncome') : t('transaction.directionTransfer');
const directionColor = isExpense ? theme.colors.financial.expense
: isIncome ? theme.colors.financial.income
: theme.colors.financial.transfer;
return (
{/* 摘要卡片 */}
{tx.narration || tx.payee || t('transaction.noSummary')}
{tx.date.slice(0, 10)}{tx.payee ? ` · ${tx.payee}` : ''}
{directionLabel}
{/* 资金流向 */}
{tx.postings.map((p, i) => {
const parts = p.account.split(':');
const rootType = parts[0];
const accountShortName = parts.slice(1).join(':') || p.account;
let categoryTag = t('account.title');
let iconName: keyof typeof Ionicons.glyphMap = 'swap-horizontal-outline';
let tagBg = `${theme.colors.accent}15`;
let tagColor = theme.colors.accent;
if (rootType === 'Assets') {
categoryTag = t('account.rootTypes.Assets');
iconName = 'wallet-outline';
tagBg = `${theme.colors.financial.income}15`;
tagColor = theme.colors.financial.income;
} else if (rootType === 'Expenses') {
categoryTag = t('account.rootTypes.Expenses');
iconName = 'cart-outline';
tagBg = `${theme.colors.financial.expense}15`;
tagColor = theme.colors.financial.expense;
} else if (rootType === 'Income') {
categoryTag = t('account.rootTypes.Income');
iconName = 'cash-outline';
tagBg = `${theme.colors.financial.income}15`;
tagColor = theme.colors.financial.income;
} else if (rootType === 'Liabilities') {
categoryTag = t('account.rootTypes.Liabilities');
iconName = 'card-outline';
tagBg = `${theme.colors.warning}15`;
tagColor = theme.colors.warning;
} else if (rootType === 'Equity') {
categoryTag = t('account.rootTypes.Equity');
iconName = 'options-outline';
}
const amountVal = parseFloat(p.amount ?? '0');
const isNegative = amountVal < 0;
return (
{categoryTag}
{accountShortName}
{p.metadata && Object.entries(p.metadata).map(([k, v]) => (
{k}: {v}
))}
{p.amount && (
{p.amount} {p.currency ?? ''}
)}
{p.cost && (
成本: {p.cost}
)}
{p.price && (
单价: @ {p.price}
)}
);
})}
{/* 标签 */}
{tx.tags.length > 0 && (
{tx.tags.map(tag => (
#{tag}
))}
)}
{/* 链接 */}
{(tx.links.length > 0 || isEditable) && (
{tx.links.map(link => (
^{link}
{isEditable && (
handleUnlink(link)} style={{ marginLeft: 6 }}>
)}
))}
{isEditable && (
setLinkModalVisible(true)}
style={[styles.linkBtn, { borderColor: theme.colors.accent, marginTop: tx.links.length > 0 ? 12 : 0 }]}
>
{t('transaction.linkTransaction')}
)}
{!isEditable && tx.links.length === 0 && (
{t('transaction.readOnlyLinks')}
)}
)}
{/* 关联的交易列表 */}
{relatedTransactions.length > 0 && (
{relatedTransactions.map(item => (
router.push(`/transaction/${target.id}`)}
/>
))}
)}
{/* 元数据 / 附加信息 */}
{tx.metadata && Object.keys(tx.metadata).length > 0 && (
{Object.entries(tx.metadata).map(([k, v]) => (
{k}
{v}
))}
)}
{/* 原始 .bean */}
{tx.raw}
{t('transaction.source')}: {tx.source}
{/* 编辑/删除按钮(所有本地 main.bean 交易均可操作) */}
{isEditable && (
useNumpadUiStore.getState().open({ editId: id })}
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.accent, opacity: pressed ? 0.7 : 1 }]}
>
{t('transaction.edit')}
{
Alert.alert(t('transaction.deleteTitle'), t('transaction.deleteConfirm'), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'), style: 'destructive',
onPress: async () => {
try {
await useLedgerStore.getState().deleteTransaction(tx.raw);
Alert.alert(t('transaction.deleteSuccess'));
router.back();
} catch (e) {
Alert.alert('Error', String(e));
}
},
},
]);
}}
style={({ pressed }) => [styles.editBtn, { borderColor: theme.colors.error, opacity: pressed ? 0.7 : 1 }]}
>
{t('transaction.delete')}
)}
{/* 关联交易筛选模态框 */}
setLinkModalVisible(false)}
>
setLinkModalVisible(false)}>
{t('transaction.linkMobileTx')}
item.id}
renderItem={({ item }) => (
handleLinkTransaction(item)}
/>
)}
ListEmptyComponent={
{t('transaction.noLinkableTx')}
}
/>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16 },
summaryRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
directionBadge: { paddingVertical: 4, paddingHorizontal: 10, borderRadius: 12 },
postingRow: { flexDirection: 'row', paddingTop: 10, gap: 8 },
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
tag: { paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
tagContainer: { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4 },
linkBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 8 },
searchBarContainer: { paddingHorizontal: 16, paddingBottom: 8 },
searchInput: { height: 40, borderWidth: StyleSheet.hairlineWidth, borderRadius: 8, paddingHorizontal: 12, fontSize: 14 },
metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
editActions: { flexDirection: 'row', gap: 12, justifyContent: 'center' },
editBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, borderRadius: 16, paddingVertical: 10, paddingHorizontal: 20 },
flowCardItem: { flexDirection: 'row', alignItems: 'center', padding: 10 },
flowIconBox: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' },
miniTypeTag: { paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4 },
});