Allow entries to span multiple ledgers
This commit is contained in:
parent
d631bb40d8
commit
50dfafc1d2
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { EntryType } from "@cents/domain";
|
||||
import { BookOpen, CalendarDays, Check, ChevronDown, ChevronRight, Delete as BackspaceIcon } from "@lucide/vue";
|
||||
import { BookOpen, CalendarDays, Check, ChevronDown, ChevronRight, Delete as BackspaceIcon, X } from "@lucide/vue";
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
categories,
|
||||
|
|
@ -15,20 +15,20 @@ type SavedEntry = {
|
|||
categoryId: string;
|
||||
note: string;
|
||||
occurredAt: string;
|
||||
ledgerIds: string[];
|
||||
};
|
||||
|
||||
type SelectorLevel = "type" | "category" | "subcategory";
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
ledgerId: string;
|
||||
ledgerName: string;
|
||||
ledgerOptions: Array<{ id: string; name: string }>;
|
||||
ledgerOptions: Array<{ id: string; name: string; color: string }>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
save: [entry: SavedEntry];
|
||||
changeLedger: [ledgerId: string];
|
||||
}>();
|
||||
|
||||
const expression = ref("0");
|
||||
|
|
@ -40,6 +40,8 @@ const dateInput = ref<HTMLInputElement | null>(null);
|
|||
const selectorOpen = ref(false);
|
||||
const selectorLevel = ref<SelectorLevel>("type");
|
||||
const noteEditing = ref(false);
|
||||
const ledgerPickerOpen = ref(false);
|
||||
const selectedLedgerIds = ref([props.ledgerId]);
|
||||
const axisOffset = ref(0);
|
||||
let axisPointerId: number | null = null;
|
||||
let axisDragStartY = 0;
|
||||
|
|
@ -65,6 +67,9 @@ const displayAmount = computed(() =>
|
|||
maximumFractionDigits: 2,
|
||||
}).format(calculatedAmount.value),
|
||||
);
|
||||
const selectedLedgerLabel = computed(() => selectedLedgerIds.value.length === 1
|
||||
? props.ledgerOptions.find((ledger) => ledger.id === selectedLedgerIds.value[0])?.name ?? props.ledgerName
|
||||
: `${selectedLedgerIds.value.length} 个账本`);
|
||||
const dateLabel = computed(() => {
|
||||
const date = new Date(occurredAt.value);
|
||||
const now = new Date();
|
||||
|
|
@ -315,6 +320,22 @@ function startNoteEditing() {
|
|||
noteEditing.value = true;
|
||||
}
|
||||
|
||||
function openLedgerPicker() {
|
||||
confirmCategory();
|
||||
noteEditing.value = false;
|
||||
ledgerPickerOpen.value = true;
|
||||
}
|
||||
|
||||
function toggleLedger(ledgerId: string) {
|
||||
if (selectedLedgerIds.value.includes(ledgerId)) {
|
||||
if (selectedLedgerIds.value.length > 1) {
|
||||
selectedLedgerIds.value = selectedLedgerIds.value.filter((id) => id !== ledgerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
selectedLedgerIds.value = [...selectedLedgerIds.value, ledgerId];
|
||||
}
|
||||
|
||||
function saveEntry() {
|
||||
const amount = Math.round(calculatedAmount.value * 100);
|
||||
if (!Number.isFinite(amount) || amount <= 0) return;
|
||||
|
|
@ -325,6 +346,7 @@ function saveEntry() {
|
|||
categoryId: selectedCategory.value?.id ?? entryType.value,
|
||||
note: note.value.trim(),
|
||||
occurredAt: new Date(occurredAt.value).toISOString(),
|
||||
ledgerIds: selectedLedgerIds.value,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
@ -342,18 +364,11 @@ function saveEntry() {
|
|||
<strong class="amount-value">{{ displayAmount }}</strong>
|
||||
</div>
|
||||
<span v-if="hasCalculation" class="calculation-line">{{ calculationLabel }}</span>
|
||||
<label class="quick-ledger-target">
|
||||
<button class="quick-ledger-target" type="button" @click="openLedgerPicker">
|
||||
<BookOpen :size="13" />
|
||||
<span>记入 {{ ledgerName }}</span>
|
||||
<span>记入 {{ selectedLedgerLabel }}</span>
|
||||
<ChevronDown :size="13" />
|
||||
<select
|
||||
:value="ledgerId"
|
||||
aria-label="记入账本"
|
||||
@change="emit('changeLedger', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="ledger in ledgerOptions" :key="ledger.id" :value="ledger.id">{{ ledger.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</button>
|
||||
</div>
|
||||
<button class="header-date-button" type="button" @click="openDatePicker">
|
||||
<CalendarDays :size="16" />
|
||||
|
|
@ -503,4 +518,20 @@ function saveEntry() {
|
|||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="ledgerPickerOpen" class="quick-ledger-picker-layer">
|
||||
<button class="quick-ledger-picker-scrim" type="button" aria-label="关闭账本选择" @click="ledgerPickerOpen = false"></button>
|
||||
<section class="quick-ledger-picker" aria-label="选择关联账本">
|
||||
<div class="quick-ledger-picker-handle"></div>
|
||||
<header><div><strong>选择账本</strong><span>至少选择一个账本</span></div><button type="button" aria-label="关闭" @click="ledgerPickerOpen = false"><X :size="19" /></button></header>
|
||||
<div class="quick-ledger-options">
|
||||
<button v-for="ledger in ledgerOptions" :key="ledger.id" type="button" :class="{ selected: selectedLedgerIds.includes(ledger.id) }" @click="toggleLedger(ledger.id)">
|
||||
<span :style="{ background: ledger.color }"><BookOpen :size="17" /></span>
|
||||
<strong>{{ ledger.name }}</strong>
|
||||
<Check v-if="selectedLedgerIds.includes(ledger.id)" :size="19" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="quick-ledger-picker-done" type="button" @click="ledgerPickerOpen = false"><Check :size="18" />完成</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -16,29 +16,25 @@ const drawerOpen = ref(false);
|
|||
const savedToast = ref(false);
|
||||
|
||||
const ledgerOptions = computed(() =>
|
||||
ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name })),
|
||||
ledgerStore.ledgers.map((ledger) => ({ id: ledger.id, name: ledger.name, color: ledger.color })),
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!ledgerStore.loaded) await ledgerStore.loadLedgers();
|
||||
});
|
||||
|
||||
async function changeLedger(ledgerId: string) {
|
||||
await ledgerStore.setCurrentLedger(ledgerId);
|
||||
}
|
||||
|
||||
async function saveEntry(input: {
|
||||
type: "expense" | "income";
|
||||
amount: number;
|
||||
categoryId: string;
|
||||
note: string;
|
||||
occurredAt: string;
|
||||
ledgerIds: string[];
|
||||
}) {
|
||||
const ledger = ledgerStore.currentLedger;
|
||||
if (!ledger) return;
|
||||
await entryStore.addEntry(createEntry({
|
||||
...input,
|
||||
ledgerId: ledger.id,
|
||||
currency: ledger.defaultCurrency,
|
||||
createdBy: authStore.user?.id,
|
||||
}));
|
||||
|
|
@ -67,7 +63,6 @@ async function saveEntry(input: {
|
|||
:ledger-id="ledgerStore.currentLedger.id"
|
||||
:ledger-name="ledgerStore.currentLedger.name"
|
||||
:ledger-options="ledgerOptions"
|
||||
@change-ledger="changeLedger"
|
||||
@close="drawerOpen = false"
|
||||
@save="saveEntry"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -35,3 +35,31 @@ db.version(3).stores({
|
|||
userPreferences: "id, userId, key, updatedAt",
|
||||
syncMetadata: "id, updatedAt",
|
||||
});
|
||||
|
||||
type LegacyEntry = LedgerEntry & { ledgerId?: string; ownerId?: string; ledgerIds?: string[] };
|
||||
type LegacyOperation = SyncOperation & { ledgerId?: string; ledgerIds?: string[]; payload: LegacyEntry };
|
||||
|
||||
db.version(4).stores({
|
||||
entries: "id, ownerId, *ledgerIds, occurredAt, updatedAt, deletedAt",
|
||||
syncOperations: "id, *ledgerIds, createdAt, syncedAt",
|
||||
ledgers: "id, updatedAt, archivedAt",
|
||||
userPreferences: "id, userId, key, updatedAt",
|
||||
syncMetadata: "id, updatedAt",
|
||||
}).upgrade(async (transaction) => {
|
||||
await transaction.table<LegacyEntry>("entries").toCollection().modify((entry) => {
|
||||
entry.ownerId ??= entry.createdBy;
|
||||
entry.ledgerIds = entry.ledgerIds?.length ? [...new Set(entry.ledgerIds)] : entry.ledgerId ? [entry.ledgerId] : [];
|
||||
delete entry.ledgerId;
|
||||
});
|
||||
await transaction.table<LegacyOperation>("syncOperations").toCollection().modify((operation) => {
|
||||
operation.payload.ownerId ??= operation.payload.createdBy;
|
||||
operation.payload.ledgerIds = operation.payload.ledgerIds?.length
|
||||
? [...new Set(operation.payload.ledgerIds)]
|
||||
: operation.payload.ledgerId ? [operation.payload.ledgerId] : [];
|
||||
operation.ledgerIds = operation.ledgerIds?.length
|
||||
? [...new Set(operation.ledgerIds)]
|
||||
: operation.ledgerId ? [operation.ledgerId] : operation.payload.ledgerIds;
|
||||
delete operation.payload.ledgerId;
|
||||
delete operation.ledgerId;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { CurrencyCode, EntryType, LedgerEntry } from "@cents/domain";
|
|||
import { createId } from "./ids";
|
||||
|
||||
type EntryInput = {
|
||||
ledgerId: string;
|
||||
ledgerIds: string[];
|
||||
type: EntryType;
|
||||
amount: number;
|
||||
currency: CurrencyCode;
|
||||
|
|
@ -19,7 +19,8 @@ export function createEntry(input: EntryInput): LedgerEntry {
|
|||
|
||||
return {
|
||||
id: createId(),
|
||||
ledgerId: input.ledgerId,
|
||||
ownerId: input.createdBy ?? "local-user",
|
||||
ledgerIds: input.ledgerIds,
|
||||
type: input.type,
|
||||
amount: input.amount,
|
||||
currency: input.currency,
|
||||
|
|
@ -49,10 +50,10 @@ export function createDemoEntries(): LedgerEntry[] {
|
|||
};
|
||||
|
||||
return [
|
||||
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 5800, currency: "CNY", categoryId: "takeout", note: "午餐", occurredAt: at(0, 12, 28) }),
|
||||
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 3150, currency: "CNY", categoryId: "taxi", note: "打车", occurredAt: at(0, 9, 16) }),
|
||||
createEntry({ ledgerId: "local-ledger", type: "income", amount: 824000, currency: "CNY", categoryId: "salary", note: "工资", occurredAt: at(1, 18, 2) }),
|
||||
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 23680, currency: "CNY", categoryId: "groceries", note: "超市采购", occurredAt: at(1, 16, 45) }),
|
||||
createEntry({ ledgerId: "local-ledger", type: "expense", amount: 2600, currency: "CNY", categoryId: "coffee", note: "咖啡", occurredAt: at(2, 14, 10) }),
|
||||
createEntry({ ledgerIds: ["local-ledger"], type: "expense", amount: 5800, currency: "CNY", categoryId: "takeout", note: "午餐", occurredAt: at(0, 12, 28) }),
|
||||
createEntry({ ledgerIds: ["local-ledger"], type: "expense", amount: 3150, currency: "CNY", categoryId: "taxi", note: "打车", occurredAt: at(0, 9, 16) }),
|
||||
createEntry({ ledgerIds: ["local-ledger"], type: "income", amount: 824000, currency: "CNY", categoryId: "salary", note: "工资", occurredAt: at(1, 18, 2) }),
|
||||
createEntry({ ledgerIds: ["local-ledger"], type: "expense", amount: 23680, currency: "CNY", categoryId: "groceries", note: "超市采购", occurredAt: at(1, 16, 45) }),
|
||||
createEntry({ ledgerIds: ["local-ledger"], type: "expense", amount: 2600, currency: "CNY", categoryId: "coffee", note: "咖啡", occurredAt: at(2, 14, 10) }),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export const useEntryStore = defineStore("entries", {
|
|||
state: () => ({
|
||||
entries: [] as LedgerEntry[],
|
||||
pendingEntryIds: [] as string[],
|
||||
pendingEntries: [] as Array<{ id: string; ledgerId: string }>,
|
||||
pendingEntries: [] as Array<{ id: string; ledgerIds: string[] }>,
|
||||
syncing: false,
|
||||
syncError: "",
|
||||
lastSyncedAt: "" as string,
|
||||
|
|
@ -30,14 +30,14 @@ export const useEntryStore = defineStore("entries", {
|
|||
const pendingOperations = await db.syncOperations.filter((operation) => operation.syncedAt === null).toArray();
|
||||
this.pendingEntryIds = [...new Set(pendingOperations.map((operation) => operation.entityId))];
|
||||
this.pendingEntries = [...new Map(
|
||||
pendingOperations.map((operation) => [operation.entityId, { id: operation.entityId, ledgerId: operation.ledgerId }]),
|
||||
pendingOperations.map((operation) => [operation.entityId, { id: operation.entityId, ledgerIds: operation.ledgerIds }]),
|
||||
).values()];
|
||||
},
|
||||
isEntryPending(entryId: string) {
|
||||
return this.pendingEntryIds.includes(entryId);
|
||||
},
|
||||
pendingEntryCount(ledgerId: string) {
|
||||
return this.pendingEntries.filter((entry) => entry.ledgerId === ledgerId).length;
|
||||
return this.pendingEntries.filter((entry) => entry.ledgerIds.includes(ledgerId)).length;
|
||||
},
|
||||
startSyncTriggers() {
|
||||
if (syncTriggersStarted || typeof window === "undefined") return;
|
||||
|
|
@ -87,9 +87,15 @@ export const useEntryStore = defineStore("entries", {
|
|||
.filter((operation) => operation.syncedAt === null)
|
||||
.toArray();
|
||||
const locallyChangedIds = new Set(remainingOperations.map((operation) => operation.entityId));
|
||||
const safeCloudEntries = pulled.entries.filter((entry) => !locallyChangedIds.has(entry.id));
|
||||
const safeCloudEntries = pulled.entries.filter(
|
||||
(entry) => !locallyChangedIds.has(entry.id) && (entry.ownerId === userId || entry.ledgerIds.length > 0),
|
||||
);
|
||||
const inaccessibleEntryIds = pulled.entries
|
||||
.filter((entry) => !locallyChangedIds.has(entry.id) && entry.ownerId !== userId && entry.ledgerIds.length === 0)
|
||||
.map((entry) => entry.id);
|
||||
await db.transaction("rw", db.entries, db.syncMetadata, async () => {
|
||||
if (safeCloudEntries.length) await db.entries.bulkPut(safeCloudEntries);
|
||||
if (inaccessibleEntryIds.length) await db.entries.bulkDelete(inaccessibleEntryIds);
|
||||
if (pulled.cursor) {
|
||||
await db.syncMetadata.put({ id: userId, cursor: pulled.cursor, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
|
|
@ -116,7 +122,7 @@ export const useEntryStore = defineStore("entries", {
|
|||
const operation: SyncOperation = {
|
||||
id: createId(),
|
||||
userId: auth.user?.id,
|
||||
ledgerId: entry.ledgerId,
|
||||
ledgerIds: entry.ledgerIds,
|
||||
entity: "entry",
|
||||
entityId: entry.id,
|
||||
action: "create",
|
||||
|
|
@ -148,7 +154,7 @@ export const useEntryStore = defineStore("entries", {
|
|||
const operation: SyncOperation = {
|
||||
id: createId(),
|
||||
userId: auth.user?.id,
|
||||
ledgerId: updated.ledgerId,
|
||||
ledgerIds: updated.ledgerIds,
|
||||
entity: "entry",
|
||||
entityId: updated.id,
|
||||
action: "update",
|
||||
|
|
@ -182,7 +188,7 @@ export const useEntryStore = defineStore("entries", {
|
|||
const operation: SyncOperation = {
|
||||
id: createId(),
|
||||
userId: auth.user?.id,
|
||||
ledgerId: deleted.ledgerId,
|
||||
ledgerIds: deleted.ledgerIds,
|
||||
entity: "entry",
|
||||
entityId: deleted.id,
|
||||
action: "delete",
|
||||
|
|
|
|||
|
|
@ -40,11 +40,20 @@ export const useLedgerStore = defineStore("ledgers", {
|
|||
this.ledgers = serverLedgers;
|
||||
|
||||
const allowedIds = new Set(this.ledgers.map((ledger) => ledger.id));
|
||||
const inaccessibleEntries = await db.entries.filter((entry) => !allowedIds.has(entry.ledgerId)).primaryKeys();
|
||||
const inaccessibleOperations = await db.syncOperations.filter((item) => !allowedIds.has(item.ledgerId)).primaryKeys();
|
||||
const localEntries = await db.entries.toArray();
|
||||
const inaccessibleEntries = localEntries
|
||||
.filter((entry) => entry.ownerId !== auth.user!.id && !entry.ledgerIds.some((ledgerId) => allowedIds.has(ledgerId)))
|
||||
.map((entry) => entry.id);
|
||||
const projectedEntries = localEntries
|
||||
.filter((entry) => entry.ownerId !== auth.user!.id && entry.ledgerIds.some((ledgerId) => !allowedIds.has(ledgerId)))
|
||||
.map((entry) => ({ ...entry, ledgerIds: entry.ledgerIds.filter((ledgerId) => allowedIds.has(ledgerId)) }));
|
||||
const inaccessibleOperations = await db.syncOperations
|
||||
.filter((item) => !item.ledgerIds.some((ledgerId) => allowedIds.has(ledgerId)))
|
||||
.primaryKeys();
|
||||
await db.transaction("rw", db.ledgers, db.entries, db.syncOperations, async () => {
|
||||
await db.ledgers.clear();
|
||||
if (serverLedgers.length) await db.ledgers.bulkPut(serverLedgers);
|
||||
if (projectedEntries.length) await db.entries.bulkPut(projectedEntries);
|
||||
if (inaccessibleEntries.length) await db.entries.bulkDelete(inaccessibleEntries);
|
||||
if (inaccessibleOperations.length) await db.syncOperations.bulkDelete(inaccessibleOperations);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -672,6 +672,7 @@ input:focus-visible {
|
|||
color: #087f72;
|
||||
font-size: 11px;
|
||||
font-weight: 680;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.quick-ledger-target span {
|
||||
|
|
@ -680,13 +681,22 @@ input:focus-visible {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-ledger-target select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.quick-ledger-picker-layer { position:absolute; inset:0; z-index:30; }
|
||||
.quick-ledger-picker-scrim { position:absolute; inset:0; width:100%; height:100%; border:0; background:rgba(18,35,32,.42); backdrop-filter:blur(2px); }
|
||||
.quick-ledger-picker { position:absolute; right:0; bottom:0; left:0; max-height:90%; overflow-y:auto; border-radius:14px 14px 0 0; padding:0 16px calc(16px + env(safe-area-inset-bottom)); background:#fff; color:#26342f; }
|
||||
.quick-ledger-picker-handle { width:38px; height:4px; margin:8px auto 3px; border-radius:2px; background:#b9cac5; }
|
||||
.quick-ledger-picker > header { min-height:56px; display:flex; align-items:center; justify-content:space-between; }
|
||||
.quick-ledger-picker > header > div { display:grid; gap:1px; }
|
||||
.quick-ledger-picker > header strong { font-size:16px; }
|
||||
.quick-ledger-picker > header span { color:#7b8884; font-size:11px; }
|
||||
.quick-ledger-picker > header > button { width:36px; height:36px; display:grid; place-items:center; border:0; border-radius:8px; background:#f0f5f3; color:#536762; }
|
||||
.quick-ledger-options { border-top:1px solid #e1ebe8; border-bottom:1px solid #e1ebe8; }
|
||||
.quick-ledger-options > button { width:100%; min-height:54px; display:grid; grid-template-columns:36px minmax(0,1fr) 24px; align-items:center; gap:10px; border:0; border-bottom:1px solid #edf2f0; padding:7px 2px; background:#fff; color:#26342f; text-align:left; }
|
||||
.quick-ledger-options > button:last-child { border-bottom:0; }
|
||||
.quick-ledger-options > button > span { width:34px; height:34px; display:grid; place-items:center; border-radius:8px; color:#fff; }
|
||||
.quick-ledger-options > button > strong { overflow:hidden; font-size:14px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.quick-ledger-options > button > svg { color:#087f72; }
|
||||
.quick-ledger-picker-done { width:100%; height:46px; display:flex; align-items:center; justify-content:center; gap:6px; margin-top:14px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
|
||||
|
||||
.amount-result {
|
||||
min-width: 0;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
BookOpen,
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleUserRound,
|
||||
CloudOff,
|
||||
|
|
@ -38,10 +37,11 @@ const saving = ref(false);
|
|||
const savedToast = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const categorySheetMode = ref<CategorySheetMode | null>(null);
|
||||
const ledgerPickerOpen = ref(false);
|
||||
const dateInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const amount = ref("");
|
||||
const ledgerId = ref("local-ledger");
|
||||
const ledgerIds = ref<string[]>([]);
|
||||
const parentCategoryId = ref("");
|
||||
const childCategoryId = ref("");
|
||||
const note = ref("");
|
||||
|
|
@ -78,7 +78,9 @@ const timeInvalid = computed(() => {
|
|||
return Number.isNaN(new Date(occurredAt.value).getTime());
|
||||
});
|
||||
const ledgerLabel = computed(() =>
|
||||
ledgerOptions.value.find((ledger) => ledger.id === ledgerId.value)?.label ?? "未知账本",
|
||||
ledgerIds.value.length === 1
|
||||
? ledgerOptions.value.find((ledger) => ledger.id === ledgerIds.value[0])?.label ?? "未知账本"
|
||||
: `${ledgerIds.value.length} 个账本`,
|
||||
);
|
||||
const createdAtLabel = computed(() => {
|
||||
if (!entry.value) return "";
|
||||
|
|
@ -102,7 +104,7 @@ onMounted(async () => {
|
|||
function hydrate(value: LedgerEntry) {
|
||||
entry.value = value;
|
||||
amount.value = (value.amount / 100).toFixed(2);
|
||||
ledgerId.value = value.ledgerId;
|
||||
ledgerIds.value = [...value.ledgerIds];
|
||||
note.value = value.note;
|
||||
occurredAt.value = toLocalDateTime(new Date(value.occurredAt));
|
||||
const path = findCategoryPath(value.type, value.categoryId);
|
||||
|
|
@ -134,6 +136,14 @@ function chooseChild(category: Category) {
|
|||
categorySheetMode.value = null;
|
||||
}
|
||||
|
||||
function toggleLedger(ledgerId: string) {
|
||||
if (ledgerIds.value.includes(ledgerId)) {
|
||||
if (ledgerIds.value.length > 1) ledgerIds.value = ledgerIds.value.filter((id) => id !== ledgerId);
|
||||
return;
|
||||
}
|
||||
ledgerIds.value = [...ledgerIds.value, ledgerId];
|
||||
}
|
||||
|
||||
async function saveEntry() {
|
||||
if (!entry.value || saving.value) return;
|
||||
const numericAmount = Number(amount.value.replaceAll(",", ""));
|
||||
|
|
@ -159,7 +169,7 @@ async function saveEntry() {
|
|||
...entry.value,
|
||||
amount: cents,
|
||||
baseAmount: Math.round(cents * exchangeRate),
|
||||
ledgerId: ledgerId.value,
|
||||
ledgerIds: ledgerIds.value,
|
||||
categoryId: childCategoryId.value || parentCategoryId.value,
|
||||
note: note.value.trim(),
|
||||
occurredAt: occurredDate.toISOString(),
|
||||
|
|
@ -250,18 +260,15 @@ async function deleteEntry() {
|
|||
tabindex="-1"
|
||||
/>
|
||||
|
||||
<label class="entry-edit-row entry-select-row">
|
||||
<button class="entry-edit-row" type="button" @click="ledgerPickerOpen = true">
|
||||
<span class="entry-field-icon ledger"><BookOpen :size="19" /></span>
|
||||
<span class="entry-field-copy"><small>账本</small><strong>{{ ledgerLabel }}</strong></span>
|
||||
<select v-model="ledgerId" aria-label="账本">
|
||||
<option v-for="ledger in ledgerOptions" :key="ledger.id" :value="ledger.id">{{ ledger.label }}</option>
|
||||
</select>
|
||||
<ChevronDown :size="18" />
|
||||
</label>
|
||||
<ChevronRight :size="18" />
|
||||
</button>
|
||||
|
||||
<div class="entry-edit-row readonly-row">
|
||||
<span class="entry-field-icon member"><CircleUserRound :size="19" /></span>
|
||||
<span class="entry-field-copy"><small>记录人</small><strong>小王</strong></span>
|
||||
<span class="entry-field-copy"><small>记录人</small><strong>{{ ledgerStore.memberName(entry.createdBy) }}</strong></span>
|
||||
</div>
|
||||
|
||||
<label class="entry-note-row">
|
||||
|
|
@ -333,6 +340,22 @@ async function deleteEntry() {
|
|||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="ledgerPickerOpen" class="quick-ledger-picker-layer">
|
||||
<button class="quick-ledger-picker-scrim" type="button" aria-label="关闭账本选择" @click="ledgerPickerOpen = false"></button>
|
||||
<section class="quick-ledger-picker" aria-label="选择关联账本">
|
||||
<div class="quick-ledger-picker-handle"></div>
|
||||
<header><div><strong>选择账本</strong><span>至少选择一个账本</span></div><button type="button" aria-label="关闭" @click="ledgerPickerOpen = false"><X :size="19" /></button></header>
|
||||
<div class="quick-ledger-options">
|
||||
<button v-for="ledger in ledgerStore.ledgers" :key="ledger.id" type="button" :class="{ selected: ledgerIds.includes(ledger.id) }" @click="toggleLedger(ledger.id)">
|
||||
<span :style="{ background: ledger.color }"><BookOpen :size="17" /></span>
|
||||
<strong>{{ ledger.name }}</strong>
|
||||
<Check v-if="ledgerIds.includes(ledger.id)" :size="19" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="quick-ledger-picker-done" type="button" @click="ledgerPickerOpen = false"><Check :size="18" />完成</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Transition name="toast">
|
||||
<div v-if="savedToast" class="entry-detail-toast" role="status"><Check :size="17" />修改已保存</div>
|
||||
</Transition>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const monthlyEntries = computed(() => {
|
|||
const current = new Date();
|
||||
return store.entries.filter((entry) => {
|
||||
const occurredAt = new Date(entry.occurredAt);
|
||||
return entry.ledgerId === ledgerStore.currentLedgerId
|
||||
return entry.ledgerIds.includes(ledgerStore.currentLedgerId)
|
||||
&& occurredAt.getFullYear() === current.getFullYear()
|
||||
&& occurredAt.getMonth() === current.getMonth();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ const errorMessage = ref("");
|
|||
|
||||
const entryCounts = computed(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const entry of entryStore.entries) counts.set(entry.ledgerId, (counts.get(entry.ledgerId) ?? 0) + 1);
|
||||
for (const entry of entryStore.entries) {
|
||||
for (const ledgerId of entry.ledgerIds) counts.set(ledgerId, (counts.get(ledgerId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,15 +5,19 @@ import { computed, onMounted, ref } from "vue";
|
|||
import { useRouter } from "vue-router";
|
||||
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
||||
import { findCategory } from "../data/categories";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
type StatsTab = "flow" | "category" | "member";
|
||||
type StatsScope = "ledger" | "personal";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const entryStore = useEntryStore();
|
||||
const ledgerStore = useLedgerStore();
|
||||
const activeTab = ref<StatsTab>("flow");
|
||||
const scope = ref<StatsScope>("ledger");
|
||||
|
||||
const monthLabel = computed(() => {
|
||||
const date = new Date();
|
||||
|
|
@ -24,7 +28,10 @@ const entries = computed(() => {
|
|||
const current = new Date();
|
||||
return entryStore.entries.filter((entry) => {
|
||||
const occurredAt = new Date(entry.occurredAt);
|
||||
return entry.ledgerId === ledgerStore.currentLedgerId
|
||||
const inScope = scope.value === "personal"
|
||||
? entry.ownerId === authStore.user?.id
|
||||
: entry.ledgerIds.includes(ledgerStore.currentLedgerId);
|
||||
return inScope
|
||||
&& occurredAt.getFullYear() === current.getFullYear()
|
||||
&& occurredAt.getMonth() === current.getMonth();
|
||||
});
|
||||
|
|
@ -107,6 +114,11 @@ function money(value: number) {
|
|||
<div class="stats-scroll">
|
||||
<button class="stats-month" type="button">{{ monthLabel }}<ChevronDown :size="14" /></button>
|
||||
|
||||
<div class="stats-scope" role="group" aria-label="统计范围">
|
||||
<button type="button" :class="{ active: scope === 'ledger' }" @click="scope = 'ledger'">当前账本</button>
|
||||
<button type="button" :class="{ active: scope === 'personal' }" @click="scope = 'personal'">我的全部</button>
|
||||
</div>
|
||||
|
||||
<section class="stats-summary" aria-label="本月统计">
|
||||
<div>
|
||||
<span>结余</span>
|
||||
|
|
@ -174,6 +186,9 @@ function money(value: number) {
|
|||
.stats-header button { display: flex; align-items: center; gap: 5px; border: 0; border-radius: 7px; padding: 7px 9px; background: rgba(255,255,255,.14); color: #fff; font-size: 13px; }
|
||||
.stats-scroll { height: calc(100% - 74px); overflow-y: auto; padding: 0 16px 104px; }
|
||||
.stats-month { height: 46px; display: flex; align-items: center; gap: 5px; border: 0; padding: 0; background: transparent; color: #53645f; font-size: 13px; }
|
||||
.stats-scope { display:grid; grid-template-columns:1fr 1fr; gap:4px; margin:0 0 12px; border-radius:8px; padding:4px; background:#e7eeec; }
|
||||
.stats-scope button { height:34px; border:0; border-radius:6px; background:transparent; color:#71807c; font-size:12px; font-weight:680; }
|
||||
.stats-scope button.active { background:#fff; color:#087f72; box-shadow:0 2px 7px rgba(28,52,47,.1); }
|
||||
.stats-summary { display: grid; grid-template-columns: 1.35fr 1fr 1fr; align-items: center; gap: 10px; border-top: 1px solid #dce7e4; border-bottom: 1px solid #dce7e4; padding: 15px 0; background: #fff; }
|
||||
.stats-summary > div { min-width: 0; display: grid; gap: 2px; padding: 0 12px; border-left: 1px solid #e1ebe8; }
|
||||
.stats-summary > div:first-child { border-left: 0; }
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ export type ExchangeRateSource = "manual" | "system";
|
|||
|
||||
export type LedgerEntry = {
|
||||
id: string;
|
||||
ledgerId: string;
|
||||
ownerId: string;
|
||||
ledgerIds: string[];
|
||||
type: EntryType;
|
||||
amount: number;
|
||||
currency: CurrencyCode;
|
||||
|
|
@ -30,7 +31,7 @@ export type SyncOperationAction = "create" | "update" | "delete";
|
|||
export type SyncOperation = {
|
||||
id: string;
|
||||
userId?: string;
|
||||
ledgerId: string;
|
||||
ledgerIds: string[];
|
||||
entity: "entry";
|
||||
entityId: string;
|
||||
action: SyncOperationAction;
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ CREATE INDEX IF NOT EXISTS sessions_lookup ON sessions (token_hash, expires_at);
|
|||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id text PRIMARY KEY,
|
||||
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
|
||||
owner_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type varchar(8) NOT NULL CHECK (type IN ('expense', 'income')),
|
||||
amount integer NOT NULL CHECK (amount > 0),
|
||||
currency varchar(3) NOT NULL,
|
||||
|
|
@ -104,8 +104,41 @@ CREATE TABLE IF NOT EXISTS entries (
|
|||
server_updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entries_ledger_occurred
|
||||
ON entries (ledger_id, occurred_at DESC);
|
||||
ALTER TABLE entries ADD COLUMN IF NOT EXISTS owner_id text REFERENCES users(id) ON DELETE CASCADE;
|
||||
UPDATE entries SET owner_id = COALESCE(created_by, updated_by) WHERE owner_id IS NULL;
|
||||
ALTER TABLE entries ALTER COLUMN owner_id SET NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_ledgers (
|
||||
entry_id text NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
|
||||
linked_at timestamptz NOT NULL DEFAULT now(),
|
||||
unlinked_at timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (entry_id, ledger_id)
|
||||
);
|
||||
|
||||
ALTER TABLE entry_ledgers ADD COLUMN IF NOT EXISTS unlinked_at timestamptz;
|
||||
ALTER TABLE entry_ledgers ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'entries' AND column_name = 'ledger_id'
|
||||
) THEN
|
||||
EXECUTE 'INSERT INTO entry_ledgers (entry_id, ledger_id)
|
||||
SELECT id, ledger_id FROM entries
|
||||
ON CONFLICT (entry_id, ledger_id) DO NOTHING';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DROP INDEX IF EXISTS entries_ledger_occurred;
|
||||
ALTER TABLE entries DROP COLUMN IF EXISTS ledger_id;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entries_owner_occurred
|
||||
ON entries (owner_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS entry_ledgers_ledger
|
||||
ON entry_ledgers (ledger_id, entry_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entry_sync_operations (
|
||||
id text PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -120,11 +120,18 @@ function validDate(value: unknown) {
|
|||
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
function validLedgerIds(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.length > 0 && value.length <= 20
|
||||
&& value.every((ledgerId) => typeof ledgerId === "string" && ledgerId.length > 0 && ledgerId.length <= 100)
|
||||
&& new Set(value).size === value.length;
|
||||
}
|
||||
|
||||
function validEntry(value: unknown): value is LedgerEntry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entry = value as Partial<LedgerEntry>;
|
||||
return typeof entry.id === "string" && entry.id.length > 0 && entry.id.length <= 100
|
||||
&& typeof entry.ledgerId === "string" && entry.ledgerId.length > 0 && entry.ledgerId.length <= 100
|
||||
&& typeof entry.ownerId === "string" && entry.ownerId.length > 0 && entry.ownerId.length <= 100
|
||||
&& validLedgerIds(entry.ledgerIds)
|
||||
&& (entry.type === "expense" || entry.type === "income")
|
||||
&& Number.isSafeInteger(entry.amount) && entry.amount! > 0 && entry.amount! <= 2_147_483_647
|
||||
&& typeof entry.currency === "string" && currencies.has(entry.currency)
|
||||
|
|
@ -142,13 +149,16 @@ function validEntry(value: unknown): value is LedgerEntry {
|
|||
function validSyncOperation(value: unknown): value is SyncOperation {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const operation = value as Partial<SyncOperation>;
|
||||
const payload = operation.payload;
|
||||
return typeof operation.id === "string" && operation.id.length > 0 && operation.id.length <= 100
|
||||
&& operation.entity === "entry"
|
||||
&& (operation.action === "create" || operation.action === "update" || operation.action === "delete")
|
||||
&& validEntry(operation.payload)
|
||||
&& operation.entityId === operation.payload.id
|
||||
&& operation.ledgerId === operation.payload.ledgerId
|
||||
&& (operation.action !== "delete" || operation.payload.deletedAt !== null);
|
||||
&& validEntry(payload)
|
||||
&& operation.entityId === payload.id
|
||||
&& validLedgerIds(operation.ledgerIds)
|
||||
&& operation.ledgerIds.length === payload.ledgerIds.length
|
||||
&& operation.ledgerIds.every((ledgerId) => payload.ledgerIds.includes(ledgerId))
|
||||
&& (operation.action !== "delete" || payload.deletedAt !== null);
|
||||
}
|
||||
|
||||
type SyncCursor = { serverUpdatedAt: string; id: string };
|
||||
|
|
@ -440,7 +450,11 @@ server.post<{ Params: { key: string } }>("/api/ledger-invitations/:key/accept",
|
|||
[invitation!.ledgerId, user.id, invitation!.role],
|
||||
);
|
||||
await client.query(
|
||||
"UPDATE entries SET server_updated_at = now() WHERE ledger_id = $1",
|
||||
`UPDATE entries e SET server_updated_at = now()
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM entry_ledgers el
|
||||
WHERE el.entry_id = e.id AND el.ledger_id = $1 AND el.unlinked_at IS NULL
|
||||
)`,
|
||||
[invitation!.ledgerId],
|
||||
);
|
||||
await client.query(
|
||||
|
|
@ -479,35 +493,43 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
|||
continue;
|
||||
}
|
||||
|
||||
const targetRole = await client.query(
|
||||
`SELECT role FROM ledger_members
|
||||
WHERE ledger_id = $1 AND user_id = $2 AND removed_at IS NULL`,
|
||||
[operation.ledgerId, user.id],
|
||||
const existing = await client.query<{ ownerId: string; canAccess: boolean }>(
|
||||
`SELECT e.owner_id AS "ownerId",
|
||||
(e.owner_id = $2 OR EXISTS (
|
||||
SELECT 1 FROM entry_ledgers el
|
||||
JOIN ledger_members m ON m.ledger_id = el.ledger_id
|
||||
WHERE el.entry_id = e.id AND el.unlinked_at IS NULL
|
||||
AND m.user_id = $2 AND m.removed_at IS NULL
|
||||
)) AS "canAccess"
|
||||
FROM entries e WHERE e.id = $1`,
|
||||
[operation.entityId, user.id],
|
||||
);
|
||||
if (!targetRole.rowCount) {
|
||||
if (existing.rows[0] && !existing.rows[0].canAccess) {
|
||||
await client.query("ROLLBACK");
|
||||
return reply.code(403).send({ error: "无权同步该账本的流水" });
|
||||
return reply.code(403).send({ error: "无权修改该流水" });
|
||||
}
|
||||
const existing = await client.query<{ ledgerId: string }>(
|
||||
`SELECT ledger_id AS "ledgerId" FROM entries WHERE id = $1`,
|
||||
[operation.entityId],
|
||||
const inaccessibleTargets = await client.query<{ count: number }>(
|
||||
`SELECT count(*)::int AS count
|
||||
FROM unnest($1::text[]) AS target(ledger_id)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ledger_members m
|
||||
WHERE m.ledger_id = target.ledger_id AND m.user_id = $2 AND m.removed_at IS NULL
|
||||
)
|
||||
AND NOT ($3::boolean AND EXISTS (
|
||||
SELECT 1 FROM entry_ledgers el
|
||||
WHERE el.entry_id = $4 AND el.ledger_id = target.ledger_id AND el.unlinked_at IS NULL
|
||||
))`,
|
||||
[operation.ledgerIds, user.id, existing.rows[0]?.ownerId === user.id, operation.entityId],
|
||||
);
|
||||
if (existing.rows[0] && existing.rows[0].ledgerId !== operation.ledgerId) {
|
||||
const sourceRole = await client.query(
|
||||
`SELECT role FROM ledger_members
|
||||
WHERE ledger_id = $1 AND user_id = $2 AND removed_at IS NULL`,
|
||||
[existing.rows[0].ledgerId, user.id],
|
||||
);
|
||||
if (!sourceRole.rowCount) {
|
||||
await client.query("ROLLBACK");
|
||||
return reply.code(403).send({ error: "无权移动该流水" });
|
||||
}
|
||||
if (inaccessibleTargets.rows[0]?.count) {
|
||||
await client.query("ROLLBACK");
|
||||
return reply.code(403).send({ error: "无权关联其中一个账本" });
|
||||
}
|
||||
|
||||
const entry = operation.payload;
|
||||
await client.query(
|
||||
const writeResult = await client.query(
|
||||
`INSERT INTO entries (
|
||||
id, ledger_id, type, amount, currency, base_currency, base_amount,
|
||||
id, owner_id, type, amount, currency, base_currency, base_amount,
|
||||
exchange_rate, exchange_rate_source, category_id, note, occurred_at,
|
||||
created_by, updated_by, created_at, updated_at, deleted_at, version
|
||||
) VALUES (
|
||||
|
|
@ -515,7 +537,6 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
|||
$13, $14, $15, $16, $17, $18
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
ledger_id = EXCLUDED.ledger_id,
|
||||
type = EXCLUDED.type,
|
||||
amount = EXCLUDED.amount,
|
||||
currency = EXCLUDED.currency,
|
||||
|
|
@ -531,15 +552,41 @@ server.post<{ Body: { operations?: unknown[] } }>("/api/sync/push", async (reque
|
|||
deleted_at = EXCLUDED.deleted_at,
|
||||
version = EXCLUDED.version,
|
||||
server_updated_at = now()
|
||||
WHERE entries.version < EXCLUDED.version
|
||||
WHERE entries.version < EXCLUDED.version
|
||||
OR (entries.version = EXCLUDED.version AND entries.updated_at <= EXCLUDED.updated_at)`,
|
||||
[
|
||||
entry.id, entry.ledgerId, entry.type, entry.amount, entry.currency, entry.baseCurrency,
|
||||
entry.id, user.id, entry.type, entry.amount, entry.currency, entry.baseCurrency,
|
||||
entry.baseAmount, entry.exchangeRate, entry.exchangeRateSource, entry.categoryId,
|
||||
entry.note, entry.occurredAt, user.id, user.id, entry.createdAt, entry.updatedAt,
|
||||
entry.deletedAt, entry.version,
|
||||
],
|
||||
);
|
||||
if (operation.action !== "delete" && writeResult.rowCount) {
|
||||
if (existing.rows[0]?.ownerId === user.id) {
|
||||
await client.query(
|
||||
`UPDATE entry_ledgers SET unlinked_at = now(), updated_at = now()
|
||||
WHERE entry_id = $1 AND unlinked_at IS NULL
|
||||
AND NOT (ledger_id = ANY($2::text[]))`,
|
||||
[entry.id, operation.ledgerIds],
|
||||
);
|
||||
} else if (existing.rows[0]) {
|
||||
await client.query(
|
||||
`UPDATE entry_ledgers el SET unlinked_at = now(), updated_at = now()
|
||||
FROM ledger_members m
|
||||
WHERE el.entry_id = $1 AND el.unlinked_at IS NULL AND el.ledger_id = m.ledger_id
|
||||
AND m.user_id = $2 AND m.removed_at IS NULL
|
||||
AND NOT (el.ledger_id = ANY($3::text[]))`,
|
||||
[entry.id, user.id, operation.ledgerIds],
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO entry_ledgers (entry_id, ledger_id)
|
||||
SELECT $1, unnest($2::text[])
|
||||
ON CONFLICT (entry_id, ledger_id) DO UPDATE
|
||||
SET unlinked_at = NULL, updated_at = now()`,
|
||||
[entry.id, operation.ledgerIds],
|
||||
);
|
||||
}
|
||||
await client.query("UPDATE entries SET server_updated_at = now() WHERE id = $1", [entry.id]);
|
||||
await client.query(
|
||||
"INSERT INTO entry_sync_operations (id, user_id) VALUES ($1, $2)",
|
||||
|
|
@ -563,20 +610,53 @@ server.get<{ Querystring: { cursor?: string } }>("/api/sync/pull", async (reques
|
|||
const cursor = request.query.cursor ? decodeSyncCursor(request.query.cursor) : null;
|
||||
if (request.query.cursor && !cursor) return reply.code(400).send({ error: "同步游标无效" });
|
||||
const result = await pool.query<LedgerEntry & { serverUpdatedAt: Date }>(
|
||||
`SELECT e.id, e.ledger_id AS "ledgerId", e.type, e.amount,
|
||||
`SELECT e.id, e.owner_id AS "ownerId",
|
||||
ARRAY(
|
||||
SELECT el.ledger_id
|
||||
FROM entry_ledgers el
|
||||
LEFT JOIN ledger_members visible_member
|
||||
ON visible_member.ledger_id = el.ledger_id
|
||||
AND visible_member.user_id = $1
|
||||
AND visible_member.removed_at IS NULL
|
||||
WHERE el.entry_id = e.id
|
||||
AND el.unlinked_at IS NULL
|
||||
AND (e.owner_id = $1 OR visible_member.user_id IS NOT NULL)
|
||||
ORDER BY el.ledger_id
|
||||
) AS "ledgerIds",
|
||||
e.type, e.amount,
|
||||
e.currency, e.base_currency AS "baseCurrency", e.base_amount AS "baseAmount",
|
||||
e.exchange_rate AS "exchangeRate", e.exchange_rate_source AS "exchangeRateSource",
|
||||
e.category_id AS "categoryId", e.note, e.occurred_at AS "occurredAt",
|
||||
e.created_by AS "createdBy", e.updated_by AS "updatedBy",
|
||||
e.created_at AS "createdAt", e.updated_at AS "updatedAt",
|
||||
e.deleted_at AS "deletedAt", e.version,
|
||||
e.server_updated_at AS "serverUpdatedAt"
|
||||
FROM entries e JOIN ledger_members m ON m.ledger_id = e.ledger_id
|
||||
WHERE m.user_id = $1 AND m.removed_at IS NULL
|
||||
entry_change.changed_at AS "serverUpdatedAt"
|
||||
FROM entries e
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT GREATEST(
|
||||
e.server_updated_at,
|
||||
COALESCE(MAX(el.updated_at) FILTER (
|
||||
WHERE e.owner_id = $1 OR change_member.user_id IS NOT NULL
|
||||
), e.server_updated_at)
|
||||
) AS changed_at
|
||||
FROM entry_ledgers el
|
||||
LEFT JOIN ledger_members change_member
|
||||
ON change_member.ledger_id = el.ledger_id
|
||||
AND change_member.user_id = $1
|
||||
AND change_member.removed_at IS NULL
|
||||
WHERE el.entry_id = e.id
|
||||
) entry_change
|
||||
WHERE (e.owner_id = $1 OR EXISTS (
|
||||
SELECT 1 FROM entry_ledgers accessible_link
|
||||
JOIN ledger_members accessible_member ON accessible_member.ledger_id = accessible_link.ledger_id
|
||||
WHERE accessible_link.entry_id = e.id
|
||||
AND accessible_member.user_id = $1
|
||||
AND accessible_member.removed_at IS NULL
|
||||
))
|
||||
AND ($2::timestamptz IS NULL
|
||||
OR e.server_updated_at > $2::timestamptz
|
||||
OR (e.server_updated_at = $2::timestamptz AND e.id > $3))
|
||||
ORDER BY e.server_updated_at, e.id
|
||||
OR entry_change.changed_at > $2::timestamptz
|
||||
OR (entry_change.changed_at = $2::timestamptz AND e.id > $3))
|
||||
ORDER BY entry_change.changed_at, e.id
|
||||
LIMIT 501`,
|
||||
[user.id, cursor?.serverUpdatedAt ?? null, cursor?.id ?? ""],
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue