diff --git a/apps/web/src/data/categories.ts b/apps/web/src/data/categories.ts index d9800ff..35af8d3 100644 --- a/apps/web/src/data/categories.ts +++ b/apps/web/src/data/categories.ts @@ -39,7 +39,6 @@ import { Pill, Puzzle, ReceiptText, - RotateCcw, School, ShieldCheck, ShoppingBag, @@ -211,7 +210,6 @@ export const categories: Record = { { id: "bonus", label: "奖金", color: "#b57a0e", tint: "#fff7dd", icon: Gift }, { id: "part-time", label: "兼职", color: "#3577c9", tint: "#edf5ff", icon: HandCoins }, { id: "investment", label: "投资", color: "#28805d", tint: "#eaf7f1", icon: ChartNoAxesCombined }, - { id: "refund", label: "报销", color: "#3c7d9b", tint: "#edf6fa", icon: RotateCcw }, { id: "other-income", label: "其他", color: "#8559cf", tint: "#f4efff", icon: Banknote }, { id: "received-red-packet", label: "红包", color: "#d94f45", tint: "#fff0ed", icon: WalletCards }, ], diff --git a/apps/web/src/data/db.ts b/apps/web/src/data/db.ts index 0f580e0..8ffb519 100644 --- a/apps/web/src/data/db.ts +++ b/apps/web/src/data/db.ts @@ -108,6 +108,22 @@ db.version(5).stores({ normalizeEntry(operation.payload); }); }); + +db.version(6).stores({ + entries: "id, ownerId, *ledgerIds, reimbursementOfEntryId, occurredAt, updatedAt, deletedAt, conversionStatus", + syncOperations: "id, *ledgerIds, createdAt, syncedAt", + ledgers: "id, updatedAt, archivedAt", + userPreferences: "id, userId, key, updatedAt", + syncMetadata: "id, updatedAt", + exchangeRates: "id, currency, requestedDate, updatedAt", +}).upgrade(async (transaction) => { + await transaction.table("entries").toCollection().modify((entry) => { + entry.reimbursementOfEntryId ??= null; + }); + await transaction.table("syncOperations").toCollection().modify((operation) => { + operation.payload.reimbursementOfEntryId ??= null; + }); +}); } const databaseCache = new Map(); diff --git a/apps/web/src/data/entries.ts b/apps/web/src/data/entries.ts index dbac951..7c1a56c 100644 --- a/apps/web/src/data/entries.ts +++ b/apps/web/src/data/entries.ts @@ -10,6 +10,7 @@ type EntryInput = { note: string; occurredAt: string; createdBy?: string; + reimbursementOfEntryId?: string | null; }; const baseCurrency: CurrencyCode = "CNY"; @@ -33,6 +34,7 @@ export function createEntry(input: EntryInput): LedgerEntry { exchangeRateEffectiveDate: isCny ? exchangeRateDate : null, exchangeRateSource: "system", conversionStatus: isCny ? "exact" : "pending", + reimbursementOfEntryId: input.reimbursementOfEntryId ?? null, categoryId: input.categoryId, note: input.note, occurredAt: input.occurredAt, diff --git a/apps/web/src/data/reimbursements.ts b/apps/web/src/data/reimbursements.ts new file mode 100644 index 0000000..5ab48ac --- /dev/null +++ b/apps/web/src/data/reimbursements.ts @@ -0,0 +1,34 @@ +import type { LedgerEntry } from "@cents/domain"; + +export function isReimbursement(entry: LedgerEntry) { + return entry.type === "income" && Boolean(entry.reimbursementOfEntryId); +} + +export function reimbursementsForExpense(entries: LedgerEntry[], expenseId: string) { + return entries.filter((entry) => + entry.deletedAt === null + && entry.reimbursementOfEntryId === expenseId, + ); +} + +export function reimbursedOriginalAmount(expense: LedgerEntry, entries: LedgerEntry[]) { + const amount = reimbursementsForExpense(entries, expense.id) + .filter((entry) => entry.currency === expense.currency) + .reduce((sum, entry) => sum + entry.amount, 0); + return Math.min(expense.amount, amount); +} + +export function reimbursedBaseAmount(expense: LedgerEntry, entries: LedgerEntry[]) { + if (expense.baseAmount === null) return null; + const reimbursed = reimbursedOriginalAmount(expense, entries); + return Math.min(expense.baseAmount, Math.round(expense.baseAmount * reimbursed / expense.amount)); +} + +export function netOriginalAmount(expense: LedgerEntry, entries: LedgerEntry[]) { + return Math.max(0, expense.amount - reimbursedOriginalAmount(expense, entries)); +} + +export function netBaseAmount(expense: LedgerEntry, entries: LedgerEntry[]) { + if (expense.baseAmount === null) return null; + return Math.max(0, expense.baseAmount - (reimbursedBaseAmount(expense, entries) ?? 0)); +} diff --git a/apps/web/src/stores/entries.ts b/apps/web/src/stores/entries.ts index 3eb968c..df11c3d 100644 --- a/apps/web/src/stores/entries.ts +++ b/apps/web/src/stores/entries.ts @@ -4,6 +4,7 @@ import { apiRequest } from "../data/api"; import { getUserDb } from "../data/db"; import { localDate } from "../data/entries"; import { createId } from "../data/ids"; +import { reimbursementsForExpense, reimbursedOriginalAmount } from "../data/reimbursements"; import { useAuthStore } from "./auth"; import { useLedgerStore } from "./ledgers"; @@ -186,6 +187,25 @@ export const useEntryStore = defineStore("entries", { }, async addEntry(entry: LedgerEntry) { const auth = useAuthStore(); + if (entry.reimbursementOfEntryId) { + const expense = this.entries.find((item) => item.id === entry.reimbursementOfEntryId); + if ( + !expense + || expense.deletedAt + || expense.type !== "expense" + || expense.ownerId !== auth.user?.id + ) throw new Error("关联的原支出无效"); + const sameLedgers = [...entry.ledgerIds].sort().join(",") === [...expense.ledgerIds].sort().join(","); + if ( + entry.type !== "income" + || entry.currency !== expense.currency + || entry.categoryId !== expense.categoryId + || !sameLedgers + ) throw new Error("报销必须继承原支出的币种、分类和账本"); + if (entry.amount > expense.amount - reimbursedOriginalAmount(expense, this.entries)) { + throw new Error("报销金额超过剩余可报销金额"); + } + } const db = await getUserDb(auth.user?.id ?? entry.ownerId); const operation: SyncOperation = { id: createId(), @@ -213,6 +233,37 @@ export const useEntryStore = defineStore("entries", { const db = await getUserDb(auth.user?.id ?? entry.ownerId); const existing = await db.entries.get(entry.id); if (!existing || existing.deletedAt) throw new Error("Entry not found"); + if (existing.reimbursementOfEntryId !== entry.reimbursementOfEntryId) { + throw new Error("不能修改报销关联"); + } + if (entry.reimbursementOfEntryId) { + const expense = this.entries.find((item) => item.id === entry.reimbursementOfEntryId); + if (!expense || expense.deletedAt || expense.ownerId !== auth.user?.id) { + throw new Error("关联的原支出无效"); + } + const otherReimbursements = reimbursementsForExpense(this.entries, expense.id) + .filter((item) => item.id !== entry.id) + .reduce((sum, item) => sum + item.amount, 0); + if ( + entry.type !== "income" + || entry.currency !== expense.currency + || entry.categoryId !== expense.categoryId + || entry.amount + otherReimbursements > expense.amount + ) throw new Error("报销金额超过剩余可报销金额"); + } else if (existing.type === "expense") { + const reimbursed = reimbursedOriginalAmount(existing, this.entries); + const sameLedgers = [...existing.ledgerIds].sort().join(",") === [...entry.ledgerIds].sort().join(","); + if ( + reimbursed > entry.amount + || (reimbursed > 0 && ( + existing.currency !== entry.currency + || existing.categoryId !== entry.categoryId + || !sameLedgers + )) + ) { + throw new Error("存在报销时不能缩小支出金额、改变分类、币种或账本"); + } + } const exchangeRateDate = localDate(entry.occurredAt); const conversionChanged = existing.amount !== entry.amount @@ -333,6 +384,9 @@ export const useEntryStore = defineStore("entries", { const db = await getUserDb(auth.user.id); const existing = await db.entries.get(entryId); if (!existing || existing.deletedAt) return; + if (existing.type === "expense" && reimbursementsForExpense(this.entries, entryId).length) { + throw new Error("存在报销记录,不能删除原支出"); + } const deletedAt = new Date().toISOString(); const deleted: LedgerEntry = { diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 0964c20..d7fcb81 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -321,6 +321,22 @@ input:focus-visible { color: #ffe6dc; } +.summary-expense-slide { + display: grid; + justify-items: end; + gap: 2px; +} + +.summary-expense-slide small { + max-width: 100%; + overflow: hidden; + color: rgba(255, 255, 255, 0.62); + font-size: 10px; + line-height: 1.15; + text-align: right; + text-overflow: ellipsis; +} + .ledger-content { position: relative; height: calc(100vh - 180px); @@ -635,6 +651,15 @@ input:focus-visible { font-variant-numeric: tabular-nums; } +.entry-value .entry-original-amount { + margin: 0 0 1px; + color: #9aaaa5; + font-size: 11px; + line-height: 1.05; + text-decoration: line-through; + font-variant-numeric: tabular-nums; +} + .entry-value .expense { color: #d84c36; } diff --git a/apps/web/src/views/EntryDetailView.vue b/apps/web/src/views/EntryDetailView.vue index 69772ae..6fae35e 100644 --- a/apps/web/src/views/EntryDetailView.vue +++ b/apps/web/src/views/EntryDetailView.vue @@ -15,6 +15,7 @@ import { CircleUserRound, CloudOff, NotebookText, + RotateCcw, Trash2, WalletCards, X, @@ -29,6 +30,14 @@ import { type Category, } from "../data/categories"; import { ledgerTheme } from "../data/ledgers"; +import { createEntry } from "../data/entries"; +import { + isReimbursement, + netOriginalAmount, + reimbursementsForExpense, + reimbursedOriginalAmount, +} from "../data/reimbursements"; +import { useAuthStore } from "../stores/auth"; import { useEntryStore } from "../stores/entries"; import { useLedgerStore } from "../stores/ledgers"; import CurrencyPicker from "../components/CurrencyPicker.vue"; @@ -38,6 +47,7 @@ type CategorySheetMode = "parent" | "child"; const route = useRoute(); const router = useRouter(); const store = useEntryStore(); +const authStore = useAuthStore(); const ledgerStore = useLedgerStore(); const entry = ref(null); const loading = ref(true); @@ -48,6 +58,11 @@ const categorySheetMode = ref(null); const ledgerPickerOpen = ref(false); const dateInput = ref(null); const currencyPickerOpen = ref(false); +const reimbursementOpen = ref(false); +const reimbursementAmount = ref(""); +const reimbursementOccurredAt = ref(""); +const reimbursementNote = ref(""); +const reimbursementSaving = ref(false); const amount = ref(""); const currency = ref("CNY"); @@ -58,8 +73,10 @@ const note = ref(""); const occurredAt = ref(""); const entryType = computed(() => entry.value?.type ?? "expense"); +const reimbursementEntry = computed(() => entry.value ? isReimbursement(entry.value) : false); +const categoryEntryType = computed(() => reimbursementEntry.value ? "expense" : entryType.value); const typeOption = computed(() => entryTypes.find((type) => type.id === entryType.value)!); -const parentCategories = computed(() => categories[entryType.value]); +const parentCategories = computed(() => categories[categoryEntryType.value]); const selectedParent = computed(() => parentCategories.value.find((category) => category.id === parentCategoryId.value), ); @@ -108,6 +125,29 @@ const conversionLabel = computed(() => { const rateDate = entry.value.exchangeRateEffectiveDate ?? entry.value.exchangeRateDate; return `约 ¥${(entry.value.baseAmount / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} · ${rateDate}${entry.value.conversionStatus === "fallback" ? " 最近汇率" : ""}`; }); +const sourceExpense = computed(() => + entry.value?.reimbursementOfEntryId + ? store.entries.find((item) => item.id === entry.value?.reimbursementOfEntryId) ?? null + : null, +); +const expenseReimbursements = computed(() => + entry.value?.type === "expense" ? reimbursementsForExpense(store.entries, entry.value.id) : [], +); +const reimbursementLocked = computed(() => reimbursementEntry.value || expenseReimbursements.value.length > 0); +const reimbursedAmount = computed(() => + entry.value?.type === "expense" ? reimbursedOriginalAmount(entry.value, store.entries) : 0, +); +const remainingReimbursement = computed(() => + entry.value?.type === "expense" ? Math.max(0, entry.value.amount - reimbursedAmount.value) : 0, +); +const netExpenseAmount = computed(() => + entry.value?.type === "expense" ? netOriginalAmount(entry.value, store.entries) : 0, +); +const canRecordReimbursement = computed(() => + entry.value?.type === "expense" + && entry.value.ownerId === authStore.user?.id + && remainingReimbursement.value > 0, +); onMounted(async () => { await Promise.all([store.loadEntries(), ledgerStore.loadLedgers()]); @@ -124,7 +164,7 @@ function hydrate(value: LedgerEntry) { ledgerIds.value = [...new Set([...value.ledgerIds, ledgerStore.personalLedger?.id].filter((id): id is string => Boolean(id)))]; note.value = value.note; occurredAt.value = toLocalDateTime(new Date(value.occurredAt)); - const path = findCategoryPath(value.type, value.categoryId); + const path = findCategoryPath(value.reimbursementOfEntryId ? "expense" : value.type, value.categoryId); parentCategoryId.value = path[0]?.id ?? ""; childCategoryId.value = path[1]?.id ?? ""; } @@ -160,6 +200,7 @@ function chooseChild(category: Category) { } function toggleLedger(ledgerId: string) { + if (reimbursementEntry.value) return; if (ledgerId === ledgerStore.personalLedger?.id) return; if (ledgerIds.value.includes(ledgerId)) { if (ledgerIds.value.length > 1) ledgerIds.value = ledgerIds.value.filter((id) => id !== ledgerId); @@ -168,6 +209,78 @@ function toggleLedger(ledgerId: string) { ledgerIds.value = [...ledgerIds.value, ledgerId]; } +function formatEntryAmount(value: number, code = currency.value) { + return new Intl.NumberFormat("zh-CN", { + style: "currency", + currency: code, + currencyDisplay: "narrowSymbol", + minimumFractionDigits: currencyMinorUnits(code), + maximumFractionDigits: currencyMinorUnits(code), + }).format(value / 10 ** currencyMinorUnits(code)); +} + +function formatShortDate(value: string) { + return new Intl.DateTimeFormat("zh-CN", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(value)); +} + +function openReimbursement() { + if (!entry.value || !canRecordReimbursement.value) return; + const minorUnits = currencyMinorUnits(entry.value.currency); + reimbursementAmount.value = (remainingReimbursement.value / 10 ** minorUnits).toFixed(minorUnits); + reimbursementOccurredAt.value = toLocalDateTime(new Date()); + reimbursementNote.value = ""; + errorMessage.value = ""; + reimbursementOpen.value = true; +} + +async function saveReimbursement() { + if (!entry.value || reimbursementSaving.value) return; + const numericAmount = Number(reimbursementAmount.value.replaceAll(",", "")); + const minorAmount = Math.round(numericAmount * 10 ** currencyMinorUnits(entry.value.currency)); + const occurredDate = new Date(reimbursementOccurredAt.value); + if ( + !Number.isFinite(numericAmount) + || minorAmount <= 0 + || minorAmount > remainingReimbursement.value + ) { + errorMessage.value = "报销金额应大于 0 且不超过剩余可报销金额"; + return; + } + if (Number.isNaN(occurredDate.getTime())) { + errorMessage.value = "请选择报销到账时间"; + return; + } + + reimbursementSaving.value = true; + errorMessage.value = ""; + try { + await store.addEntry(createEntry({ + ledgerIds: [...entry.value.ledgerIds], + type: "income", + amount: minorAmount, + currency: entry.value.currency, + categoryId: entry.value.categoryId, + note: reimbursementNote.value.trim() || `${entry.value.note || selectedCategory.value?.label || "支出"}报销`, + occurredAt: occurredDate.toISOString(), + createdBy: authStore.user?.id, + reimbursementOfEntryId: entry.value.id, + })); + reimbursementOpen.value = false; + savedToast.value = true; + window.setTimeout(() => (savedToast.value = false), 1600); + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : "记录报销失败"; + } finally { + reimbursementSaving.value = false; + } +} + async function saveEntry() { if (!entry.value || saving.value) return; const numericAmount = Number(amount.value.replaceAll(",", "")); @@ -190,7 +303,9 @@ async function saveEntry() { amount: minorAmount, currency: currency.value, ledgerIds: [...ledgerIds.value], - categoryId: childCategoryId.value || parentCategoryId.value || entryType.value, + categoryId: reimbursementEntry.value + ? entry.value.categoryId + : childCategoryId.value || parentCategoryId.value || entryType.value, note: note.value.trim(), occurredAt: occurredDate.toISOString(), }); @@ -207,11 +322,19 @@ async function saveEntry() { async function deleteEntry() { if (!entry.value) return; + if (entry.value.type === "expense" && expenseReimbursements.value.length) { + errorMessage.value = "存在报销记录,请先删除关联的报销到账"; + return; + } const count = ledgerIds.value.length; const message = `确定从这笔账目关联的全部账本中删除吗?\n\n共 ${count} 个账本,删除后无法恢复。`; if (!window.confirm(message)) return; - await store.deleteEntry(entry.value.id); - await router.back(); + try { + await store.deleteEntry(entry.value.id); + await router.back(); + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : "删除失败"; + } } @@ -237,15 +360,16 @@ async function deleteEntry() {
- + +
- {{ typeOption.label }} + {{ reimbursementEntry ? "报销到账" : typeOption.label }}