Compare commits
No commits in common. "40945def3ee762fea4f3d892a830f0ebe42c1b69" and "331d7d8b7c41a896d5ba832521cfdef9270df60d" have entirely different histories.
40945def3e
...
331d7d8b7c
@ -1,117 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[max-content_1fr] space-y-2 mb-4 items-baseline">
|
||||
<label for="dt" class="block text-gray-900 pr-4">Date</label>
|
||||
<div>
|
||||
<input type="date" class="bordered-field" id="dt" v-model="assertion.dt">
|
||||
</div>
|
||||
<label for="description" class="block text-gray-900 pr-4">Description</label>
|
||||
<div>
|
||||
<input type="text" class="bordered-field" id="description" v-model="assertion.description" placeholder=" ">
|
||||
</div>
|
||||
<label for="account" class="block text-gray-900 pr-4">Account</label>
|
||||
<div class="relative combobox">
|
||||
<input type="text" class="bordered-field peer" id="account" v-model="assertion.account" placeholder=" " autocomplete="off">
|
||||
<!--{% include 'components/accounts_combobox_inner.html' %}-->
|
||||
</div>
|
||||
<label for="amount" class="block text-gray-900 pr-4">Balance</label>
|
||||
<div class="relative shadow-sm">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span class="text-gray-500">{{ db.metadata.reporting_commodity }}</span>
|
||||
</div>
|
||||
<!-- TODO: Display existing credit assertion as credit, not as negative debit -->
|
||||
<input type="number" class="bordered-field pl-7 pr-16" step="0.01" v-model="assertion.amount_abs" placeholder="0.00">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center">
|
||||
<select class="h-full border-0 bg-transparent py-0 pl-2 pr-8 text-gray-900 focus:ring-2 focus:ring-inset focus:ring-indigo-600" v-model="assertion.sign">
|
||||
<option value="dr">Dr</option>
|
||||
<option value="cr">Cr</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4 space-x-2">
|
||||
<button class="btn-secondary text-red-600 ring-red-500" @click="deleteAssertion" v-if="assertion.id !== null">Delete</button>
|
||||
<button class="btn-primary" @click="saveAssertion">Save</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
import { DT_FORMAT, db, deserialiseAmount } from '../db.ts';
|
||||
|
||||
export interface EditingAssertion {
|
||||
id: number | null,
|
||||
dt: string,
|
||||
description: string,
|
||||
account: string,
|
||||
sign: string,
|
||||
amount_abs: string,
|
||||
}
|
||||
|
||||
const { assertion } = defineProps<{ assertion: EditingAssertion }>();
|
||||
|
||||
async function saveAssertion() {
|
||||
// Save changes to the assertion
|
||||
const amount_abs = deserialiseAmount('' + assertion.amount_abs);
|
||||
const quantity = assertion.sign === 'dr' ? amount_abs.quantity : -amount_abs.quantity;
|
||||
|
||||
const session = await db.load();
|
||||
|
||||
if (assertion.id === null) {
|
||||
await session.execute(
|
||||
`INSERT INTO balance_assertions (dt, description, account, quantity, commodity)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[dayjs(assertion.dt).format(DT_FORMAT), assertion.description, assertion.account, quantity, amount_abs.commodity]
|
||||
);
|
||||
} else {
|
||||
await session.execute(
|
||||
`UPDATE balance_assertions
|
||||
SET dt = $1, description = $2, account = $3, quantity = $4, commodity = $5
|
||||
WHERE id = $6`,
|
||||
[dayjs(assertion.dt).format(DT_FORMAT), assertion.description, assertion.account, quantity, amount_abs.commodity, assertion.id]
|
||||
);
|
||||
}
|
||||
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
|
||||
async function deleteAssertion() {
|
||||
// Delete the current assertion
|
||||
if (!await confirm('Are you sure you want to delete this balance assertion? This operation is irreversible.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await db.load();
|
||||
|
||||
await session.execute(
|
||||
`DELETE FROM balance_assertions
|
||||
WHERE id = $1`,
|
||||
[assertion.id]
|
||||
);
|
||||
|
||||
await getCurrentWindow().close();
|
||||
}
|
||||
</script>
|
@ -1,56 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<button type="button" class="relative w-full cursor-default bg-white bordered-field pl-3 pr-10 text-left" @click="isOpen = !isOpen">
|
||||
<span class="block truncate">{{ selectedValue[1] }}</span>
|
||||
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" />
|
||||
</span>
|
||||
</button>
|
||||
<ul class="absolute z-20 mt-1 max-h-60 w-full overflow-auto bg-white py-1 text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" :class="isOpen ? 'block' : 'hidden'">
|
||||
<template v-for="([categoryName, categoryItems], index) in values">
|
||||
<li class="relative cursor-default select-none py-1 pl-3 pr-9 text-gray-500 border-b border-gray-300" :class="{ 'pt-4': index > 0 }">
|
||||
<span class="block truncate text-xs font-bold uppercase">{{ categoryName }}</span>
|
||||
</li>
|
||||
<li v-for="item in categoryItems" class="group relative cursor-default select-none py-1 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-emerald-600" :data-selected="item[0] === selectedValue[0] ? 'selected' : null" @click="selectedValue = item; isOpen = false">
|
||||
<span class="block truncate group-data-[selected=selected]:font-semibold">{{ item[1] }}</span>
|
||||
<span class="hidden group-data-[selected=selected]:flex absolute inset-y-0 right-0 items-center pr-4 text-emerald-600 group-hover:text-white">
|
||||
<CheckIcon class="h-5 w-5" />
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
import { defineModel, defineProps, ref } from 'vue';
|
||||
|
||||
const { values } = defineProps<{ values: [string, [string, string][]][] }>(); // Array of [category name, [internal identifier, pretty name]]
|
||||
|
||||
const selectedValue = defineModel({ default: null! as [string, string] }); // Vue bug: Compiler produces broken code if setting default directly here
|
||||
if (selectedValue.value === null) {
|
||||
selectedValue.value = values[0][1][0];
|
||||
}
|
||||
|
||||
const isOpen = ref(false);
|
||||
</script>
|
@ -288,7 +288,7 @@
|
||||
}
|
||||
|
||||
async function deleteTransaction() {
|
||||
if (!await confirm('Are you sure you want to delete this transaction? This operation is irreversible.')) {
|
||||
if (!confirm('Are you sure you want to delete this transaction? This operation is irreversible.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -310,14 +310,14 @@
|
||||
`DELETE FROM postings
|
||||
WHERE transaction_id = $1`,
|
||||
[transaction.id]
|
||||
);
|
||||
)
|
||||
|
||||
// Delete transaction
|
||||
await dbTransaction.execute(
|
||||
`DELETE FROM transactions
|
||||
WHERE id = $1`,
|
||||
[transaction.id]
|
||||
);
|
||||
)
|
||||
|
||||
await dbTransaction.commit();
|
||||
|
||||
|
28
src/db.ts
28
src/db.ts
@ -68,10 +68,7 @@ export async function totalBalances(session: ExtendedDatabase): Promise<Map<stri
|
||||
(
|
||||
SELECT p1.account, max(p2.transaction_id) AS max_tid FROM
|
||||
(
|
||||
SELECT account, max(dt) AS max_dt
|
||||
FROM postings
|
||||
JOIN transactions ON postings.transaction_id = transactions.id
|
||||
GROUP BY account
|
||||
SELECT account, max(dt) AS max_dt FROM postings JOIN transactions ON postings.transaction_id = transactions.id GROUP BY account
|
||||
) p1
|
||||
JOIN postings p2 ON p1.account = p2.account AND p1.max_dt = transactions.dt JOIN transactions ON p2.transaction_id = transactions.id GROUP BY p2.account
|
||||
) p3
|
||||
@ -81,29 +78,6 @@ export async function totalBalances(session: ExtendedDatabase): Promise<Map<stri
|
||||
return new Map(resultsRaw.map((x) => [x.account, x.quantity]));
|
||||
}
|
||||
|
||||
export async function totalBalancesAtDate(session: ExtendedDatabase, dt: string): Promise<Map<string, number>> {
|
||||
await updateRunningBalances(session);
|
||||
|
||||
const resultsRaw: {account: string, quantity: number}[] = await session.select(
|
||||
`SELECT p3.account AS account, running_balance AS quantity FROM
|
||||
(
|
||||
SELECT p1.account, max(p2.transaction_id) AS max_tid FROM
|
||||
(
|
||||
SELECT account, max(dt) AS max_dt
|
||||
FROM postings
|
||||
JOIN transactions ON postings.transaction_id = transactions.id
|
||||
WHERE DATE(dt) <= DATE($1)
|
||||
GROUP BY account
|
||||
) p1
|
||||
JOIN postings p2 ON p1.account = p2.account AND p1.max_dt = transactions.dt JOIN transactions ON p2.transaction_id = transactions.id GROUP BY p2.account
|
||||
) p3
|
||||
JOIN postings p4 ON p3.account = p4.account AND p3.max_tid = p4.transaction_id ORDER BY account`,
|
||||
[dt]
|
||||
);
|
||||
|
||||
return new Map(resultsRaw.map((x) => [x.account, x.quantity]));
|
||||
}
|
||||
|
||||
export async function updateRunningBalances(session: ExtendedDatabase) {
|
||||
// TODO: This is very slow - it would be faster to do this in Rust
|
||||
|
||||
|
@ -30,14 +30,10 @@ async function initApp() {
|
||||
// Init router
|
||||
const routes = [
|
||||
{ path: '/', name: 'index', component: () => import('./pages/HomeView.vue') },
|
||||
{ path: '/balance-assertions', name: 'balance-assertions', component: () => import('./pages/BalanceAssertionsView.vue') },
|
||||
{ path: '/balance-assertions/edit/:id', name: 'balance-assertions-edit', component: () => import('./pages/EditBalanceAssertionView.vue') },
|
||||
{ path: '/balance-assertions/new', name: 'balance-assertions-new', component: () => import('./pages/NewBalanceAssertionView.vue') },
|
||||
{ path: '/chart-of-accounts', name: 'chart-of-accounts', component: () => import('./pages/ChartOfAccountsView.vue') },
|
||||
{ path: '/general-ledger', name: 'general-ledger', component: () => import('./pages/GeneralLedgerView.vue') },
|
||||
{ path: '/journal', name: 'journal', component: () => import('./pages/JournalView.vue') },
|
||||
{ path: '/journal/edit/:id', name: 'journal-edit-transaction', component: () => import('./pages/EditTransactionView.vue') },
|
||||
{ path: '/journal/new', name: 'journal-new-transaction', component: () => import('./pages/NewTransactionView.vue') },
|
||||
{ path: '/journal/edit-transaction/:id', name: 'journal-edit-transaction', component: () => import('./pages/EditTransactionView.vue') },
|
||||
{ path: '/journal/new-transaction', name: 'journal-new-transaction', component: () => import('./pages/NewTransactionView.vue') },
|
||||
{ path: '/statement-lines', name: 'statement-lines', component: () => import('./pages/StatementLinesView.vue') },
|
||||
{ path: '/statement-lines/import', name: 'import-statement', component: () => import('./pages/ImportStatementView.vue') },
|
||||
{ path: '/transactions/:account', name: 'transactions', component: () => import('./pages/TransactionsView.vue') },
|
||||
|
@ -1,129 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading">
|
||||
Balance assertions
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2">
|
||||
<a href="/balance-assertions/new" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
New assertion
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-300">
|
||||
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Description</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Account</th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Balance</th>
|
||||
<th></th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="assertion of balanceAssertions">
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ dayjs(assertion.dt).format('YYYY-MM-DD') }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">{{ assertion.description }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900"><RouterLink :to="{ name: 'transactions', params: { account: assertion.account } }" class="text-gray-900 hover:text-blue-700 hover:underline">{{ assertion.account }}</RouterLink></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 text-end">{{ pp(Math.abs(assertion.quantity)) }}</td>
|
||||
<td class="py-0.5 pr-1 text-gray-900">{{ assertion.quantity >= 0 ? 'Dr' : 'Cr' }}</td>
|
||||
<td class="py-0.5 px-1 text-gray-900">
|
||||
<CheckIcon class="w-4 h-4" v-if="assertion.isValid" />
|
||||
<XMarkIcon class="w-4 h-4 text-red-500" v-if="!assertion.isValid" />
|
||||
</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 text-end">
|
||||
<a :href="'/balance-assertions/edit/' + assertion.id" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { db, totalBalancesAtDate } from '../db.ts';
|
||||
import { pp } from '../display.ts';
|
||||
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
|
||||
const balanceAssertions = ref([] as ValidatedBalanceAssertion[]);
|
||||
|
||||
interface ValidatedBalanceAssertion {
|
||||
id: number,
|
||||
dt: string,
|
||||
description: string,
|
||||
account: string,
|
||||
quantity: number,
|
||||
commodity: string,
|
||||
isValid: boolean,
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
|
||||
const rawBalanceAssertions: any[] = await session.select(
|
||||
`SELECT *
|
||||
FROM balance_assertions
|
||||
ORDER BY dt DESC, id DESC`
|
||||
);
|
||||
|
||||
/*
|
||||
// Cache trial balances in case there are multiple assertions per date
|
||||
const trialBalanceForDate = new Map<string, TrialBalanceReport>();
|
||||
|
||||
for (const balanceAssertion of rawBalanceAssertions) {
|
||||
// Check assertion status
|
||||
// TODO: This is very inefficient because API transactions are generated multiple times
|
||||
if (!trialBalanceForDate.has(balanceAssertion.dt)) {
|
||||
const reportingWorkflow = new ReportingWorkflow();
|
||||
await reportingWorkflow.generate(session, balanceAssertion.dt);
|
||||
const trialBalance = reportingWorkflow.getReportAtStage(ReportingStage.OrdinaryAPITransactions, TrialBalanceReport) as TrialBalanceReport;
|
||||
trialBalanceForDate.set(balanceAssertion.dt, trialBalance);
|
||||
}
|
||||
|
||||
const trialBalance = trialBalanceForDate.get(balanceAssertion.dt)!;
|
||||
balanceAssertion.isValid = balanceAssertion.quantity === trialBalance.balances.get(balanceAssertion.account) && balanceAssertion.commodity === db.metadata.reporting_commodity;
|
||||
}
|
||||
*/
|
||||
|
||||
// Check assertion status
|
||||
const balancesForDate = new Map<string, Map<string, number>>();
|
||||
|
||||
for (const balanceAssertion of rawBalanceAssertions) {
|
||||
if (!balancesForDate.has(balanceAssertion.dt)) {
|
||||
// FIXME: This is quite slow
|
||||
balancesForDate.set(balanceAssertion.dt, await totalBalancesAtDate(session, balanceAssertion.dt));
|
||||
}
|
||||
balanceAssertion.isValid = balanceAssertion.quantity === balancesForDate.get(balanceAssertion.dt)!.get(balanceAssertion.account) && balanceAssertion.commodity === db.metadata.reporting_commodity;
|
||||
}
|
||||
|
||||
balanceAssertions.value = rawBalanceAssertions as ValidatedBalanceAssertion[];
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
@ -1,132 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading">
|
||||
Chart of accounts
|
||||
</h1>
|
||||
|
||||
<div class="my-2 py-2 flex gap-x-2 items-baseline bg-white sticky top-0">
|
||||
<DropdownBox class="w-[450px]" :values="accountKindsByModule" v-model="selectedAccountKind" />
|
||||
<button class="btn-primary" @click="addAccountType">Add type</button>
|
||||
<button class="btn-secondary text-red-600 ring-red-500" @click="removeAccountType">Remove type</button>
|
||||
</div>
|
||||
|
||||
<table class="min-w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Account</th>
|
||||
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-start">Associated types</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-t border-gray-300" v-for="[account, thisAccountKinds] in accounts.entries()">
|
||||
<td class="py-0.5 pr-1 text-gray-900 align-baseline"><input class="checkbox-primary" type="checkbox" v-model="selectedAccounts" :value="account"></td>
|
||||
<td class="py-0.5 px-1 text-gray-900 align-baseline">{{ account }}</td>
|
||||
<td class="py-0.5 pl-1 text-gray-900 align-baseline">
|
||||
<ul class="list-disc ml-5" v-if="thisAccountKinds">
|
||||
<!-- First display known account kinds -->
|
||||
<template v-for="[accountKind, accountKindPrettyName] in accountKindsMap.entries()">
|
||||
<li v-if="thisAccountKinds.indexOf(accountKind) >= 0">{{ accountKindPrettyName }}</li>
|
||||
</template>
|
||||
<!-- Then display unknown account kinds -->
|
||||
<template v-for="accountKind in thisAccountKinds">
|
||||
<li v-if="!accountKindsMap.has(accountKind)" class="italic">{{ accountKind }}</li>
|
||||
</template>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { accountKinds } from '../registry.ts';
|
||||
import { db } from '../db.ts';
|
||||
import DropdownBox from '../components/DropdownBox.vue';
|
||||
|
||||
const accountKindsMap = new Map(accountKinds);
|
||||
const accountKindsByModule = [...Map.groupBy(accountKinds, (k) => k[0].split('.')[0]).entries()];
|
||||
|
||||
const accounts = ref(new Map<string, string[]>());
|
||||
const selectedAccounts = ref([]);
|
||||
const selectedAccountKind = ref(accountKinds[0]);
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
|
||||
const accountKindsRaw: {account: string, kind: string}[] = await session.select(
|
||||
`SELECT account, kind FROM account_configurations ORDER BY account, kind`
|
||||
);
|
||||
|
||||
for (const accountKindRaw of accountKindsRaw) {
|
||||
const kinds = accounts.value.get(accountKindRaw.account) ?? [];
|
||||
kinds.push(accountKindRaw.kind);
|
||||
accounts.value.set(accountKindRaw.account, kinds);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
|
||||
async function addAccountType() {
|
||||
// Associate selected accounts with the selected account kind
|
||||
const session = await db.load();
|
||||
const dbTransaction = await session.begin();
|
||||
|
||||
for (const account of selectedAccounts.value) {
|
||||
await dbTransaction.execute(
|
||||
`INSERT INTO account_configurations (account, kind)
|
||||
VALUES ($1, $2)`,
|
||||
[account, selectedAccountKind.value[0]]
|
||||
);
|
||||
}
|
||||
|
||||
await dbTransaction.commit();
|
||||
|
||||
selectedAccounts.value = [];
|
||||
|
||||
// Reload data
|
||||
accounts.value.clear();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function removeAccountType() {
|
||||
// De-associate selected accounts with the selected account kind
|
||||
const session = await db.load();
|
||||
const dbTransaction = await session.begin();
|
||||
|
||||
for (const account of selectedAccounts.value) {
|
||||
await dbTransaction.execute(
|
||||
`DELETE FROM account_configurations
|
||||
WHERE account = $1 AND kind = $2`,
|
||||
[account, selectedAccountKind.value[0]]
|
||||
);
|
||||
}
|
||||
|
||||
await dbTransaction.commit();
|
||||
|
||||
selectedAccounts.value = [];
|
||||
|
||||
// Reload data
|
||||
accounts.value.clear();
|
||||
await load();
|
||||
}
|
||||
</script>
|
@ -1,67 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading mb-4">
|
||||
Edit balance assertion
|
||||
</h1>
|
||||
|
||||
<BalanceAssertionEditor :assertion="assertion" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { db, serialiseAmount } from '../db.ts';
|
||||
import BalanceAssertionEditor, { EditingAssertion } from '../components/BalanceAssertionEditor.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const assertion = ref({
|
||||
id: null,
|
||||
dt: null!,
|
||||
description: null!,
|
||||
account: null!,
|
||||
sign: null!,
|
||||
amount_abs: null!,
|
||||
} as EditingAssertion);
|
||||
|
||||
async function load() {
|
||||
const session = await db.load();
|
||||
|
||||
const rawAssertions: any[] = await session.select(
|
||||
`SELECT *
|
||||
FROM balance_assertions
|
||||
WHERE id = $1`,
|
||||
[route.params.id]
|
||||
);
|
||||
const rawAssertion = rawAssertions[0];
|
||||
|
||||
// Format parameters for display
|
||||
rawAssertion.dt = dayjs(rawAssertion.dt).format('YYYY-MM-DD');
|
||||
rawAssertion.sign = rawAssertion.quantity >= 0 ? 'dr' : 'cr';
|
||||
rawAssertion.amount_abs = serialiseAmount(Math.abs(rawAssertion.quantity), rawAssertion.commodity);
|
||||
|
||||
assertion.value = rawAssertion as EditingAssertion;
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
@ -30,7 +30,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { JoinedTransactionPosting, db, joinedToTransactions, serialiseAmount } from '../db.ts';
|
||||
import { JoinedTransactionPosting, Posting, db, joinedToTransactions, serialiseAmount } from '../db.ts';
|
||||
import TransactionEditor, { EditingTransaction } from '../components/TransactionEditor.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@ -56,18 +56,16 @@
|
||||
|
||||
const transactions = joinedToTransactions(joinedTransactionPostings);
|
||||
if (transactions.length !== 1) { throw new Error('Unexpected number of transactions returned from SQL'); }
|
||||
const rawTransaction = transactions[0] as any;
|
||||
transaction.value = transactions[0] as unknown as EditingTransaction;
|
||||
|
||||
// Format dt
|
||||
rawTransaction.dt = dayjs(rawTransaction.dt).format('YYYY-MM-DD');
|
||||
transaction.value.dt = dayjs(transaction.value.dt).format('YYYY-MM-DD')
|
||||
|
||||
// Initialise sign and amount_abs
|
||||
for (const posting of rawTransaction.postings) {
|
||||
posting.sign = posting.quantity >= 0 ? 'dr' : 'cr';
|
||||
posting.amount_abs = serialiseAmount(Math.abs(posting.quantity), posting.commodity);
|
||||
for (const posting of transaction.value.postings) {
|
||||
posting.sign = (posting as unknown as Posting).quantity! >= 0 ? 'dr' : 'cr';
|
||||
posting.amount_abs = serialiseAmount(Math.abs((posting as unknown as Posting).quantity), (posting as unknown as Posting).commodity);
|
||||
}
|
||||
|
||||
transaction.value = rawTransaction as EditingTransaction;
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
|
@ -21,16 +21,14 @@
|
||||
General ledger
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2 items-center">
|
||||
<div class="my-4 flex gap-x-2">
|
||||
<!-- Use a rather than RouterLink because RouterLink adds its own event handler -->
|
||||
<a href="/journal/new-transaction" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
New transaction
|
||||
</a>
|
||||
<div class="flex items-baseline">
|
||||
<input id="only-unclassified" class="ml-3 mr-1 self-center checkbox-primary" type="checkbox" v-model="commodityDetail">
|
||||
<label for="only-unclassified" class="text-gray-900">Show commodity detail</label>
|
||||
</div>
|
||||
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
|
||||
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
|
||||
</div>
|
||||
|
||||
<div id="transaction-list" class="max-h-[100vh] overflow-y-scroll wk-aa">
|
||||
@ -97,7 +95,7 @@
|
||||
for (const transaction of transactions.value) {
|
||||
let editLink = '';
|
||||
if (transaction.id !== null) {
|
||||
editLink = `<a href="/journal/edit/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
editLink = `<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
}
|
||||
rows.push(
|
||||
`<tr class="border-t border-gray-300">
|
||||
@ -147,8 +145,7 @@
|
||||
clusterize = new Clusterize({
|
||||
'rows': rows,
|
||||
scrollElem: document.getElementById('transaction-list')!,
|
||||
contentElem: document.querySelector('#transaction-list tbody')!,
|
||||
show_no_data_row: false,
|
||||
contentElem: document.querySelector('#transaction-list tbody')!
|
||||
});
|
||||
} else {
|
||||
clusterize.update(rows);
|
||||
|
@ -23,8 +23,8 @@
|
||||
<ul class="list-disc ml-6">
|
||||
<li><RouterLink :to="{ name: 'journal' }" class="text-gray-900 hover:text-blue-700 hover:underline">Journal</RouterLink></li>
|
||||
<li><RouterLink :to="{ name: 'statement-lines' }" class="text-gray-900 hover:text-blue-700 hover:underline">Statement lines</RouterLink></li>
|
||||
<li><RouterLink :to="{ name: 'balance-assertions' }" class="text-gray-900 hover:text-blue-700 hover:underline">Balance assertions</RouterLink></li>
|
||||
<li><RouterLink :to="{ name: 'chart-of-accounts' }" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</RouterLink></li>
|
||||
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Balance assertions</a></li>-->
|
||||
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</a></li>-->
|
||||
<!-- TODO: Plugin reports -->
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -83,7 +83,7 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { defineModel, ref, useTemplateRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { StatementLine, db } from '../db.ts';
|
||||
@ -92,8 +92,8 @@
|
||||
|
||||
const fileInput = useTemplateRef('file');
|
||||
|
||||
const selectedFilename = ref('');
|
||||
const sourceAccount = ref('');
|
||||
const selectedFilename = defineModel('selectedFilename', { default: '' });
|
||||
const sourceAccount = defineModel('sourceAccount', { default: '' });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
@ -21,16 +21,14 @@
|
||||
Journal
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2 items-center">
|
||||
<div class="my-4 flex gap-x-2">
|
||||
<!-- Use a rather than RouterLink because RouterLink adds its own event handler -->
|
||||
<a href="/journal/new-transaction" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
New transaction
|
||||
</a>
|
||||
<div class="flex items-baseline">
|
||||
<input id="only-unclassified" class="ml-3 mr-1 self-center checkbox-primary" type="checkbox" v-model="commodityDetail">
|
||||
<label for="only-unclassified" class="text-gray-900">Show commodity detail</label>
|
||||
</div>
|
||||
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
|
||||
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
|
||||
</div>
|
||||
|
||||
<div id="transaction-list" class="max-h-[100vh] overflow-y-scroll wk-aa">
|
||||
@ -63,8 +61,7 @@
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { PencilIcon } from '@heroicons/vue/24/outline';
|
||||
import { PlusIcon } from '@heroicons/vue/16/solid';
|
||||
import { PencilIcon, PlusIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
@ -101,7 +98,7 @@
|
||||
<td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td>
|
||||
<td class="py-0.5 px-1 text-gray-900" colspan="3">
|
||||
${ transaction.description }
|
||||
<a href="/journal/edit/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>
|
||||
<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
@ -146,8 +143,7 @@
|
||||
clusterize = new Clusterize({
|
||||
'rows': rows,
|
||||
scrollElem: document.getElementById('transaction-list')!,
|
||||
contentElem: document.querySelector('#transaction-list tbody')!,
|
||||
show_no_data_row: false,
|
||||
contentElem: document.querySelector('#transaction-list tbody')!
|
||||
});
|
||||
} else {
|
||||
clusterize.update(rows);
|
||||
|
@ -1,41 +0,0 @@
|
||||
<!--
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<template>
|
||||
<h1 class="page-heading mb-4">
|
||||
New balance assertion
|
||||
</h1>
|
||||
|
||||
<BalanceAssertionEditor :assertion="assertion" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import BalanceAssertionEditor, { EditingAssertion } from '../components/BalanceAssertionEditor.vue';
|
||||
|
||||
const assertion = ref({
|
||||
id: null,
|
||||
dt: dayjs().format('YYYY-MM-DD'),
|
||||
description: '',
|
||||
account: '',
|
||||
sign: 'dr',
|
||||
amount_abs: '',
|
||||
} as EditingAssertion);
|
||||
</script>
|
@ -29,10 +29,9 @@
|
||||
<RouterLink :to="{ name: 'import-statement' }" class="btn-secondary">
|
||||
Import statement
|
||||
</RouterLink>
|
||||
<div class="flex items-baseline">
|
||||
<input id="only-unclassified" class="ml-3 mr-1 self-center checkbox-primary" type="checkbox" v-model="showOnlyUnclassified">
|
||||
<label for="only-unclassified" class="text-gray-900">Show only unclassified lines</label>
|
||||
</div>
|
||||
<!--<a href="{{ url_for('statement_lines', **dict(request.args, unclassified=1)) }}" class="btn-secondary">
|
||||
Show only unclassified lines
|
||||
</a>-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -65,13 +64,13 @@
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { CheckIcon, PencilIcon } from '@heroicons/vue/24/outline';
|
||||
import { PencilIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { db } from '../db.ts';
|
||||
import { renderComponent } from '../webutil.ts';
|
||||
import { ppWithCommodity } from '../display.ts';
|
||||
import { ppWithCommodity } from '../display.ts';
|
||||
|
||||
interface StatementLine {
|
||||
id: number,
|
||||
@ -85,7 +84,6 @@
|
||||
posting_accounts: string[]
|
||||
}
|
||||
|
||||
const showOnlyUnclassified = ref(false);
|
||||
const statementLines = ref([] as StatementLine[]);
|
||||
let clusterize: Clusterize | null = null;
|
||||
|
||||
@ -127,105 +125,6 @@
|
||||
statementLines.value = newStatementLines;
|
||||
}
|
||||
|
||||
// TODO: Could probably avoid polluting global scope by using clusterize clusterChanged callback
|
||||
(window as any).showClassifyLinePanel = function(el: HTMLAnchorElement) {
|
||||
const CheckIconHTML = renderComponent(CheckIcon, { 'class': 'w-5 h-5' });
|
||||
|
||||
const td = el.closest('td')!;
|
||||
td.className = 'relative'; // CSS trickery so as to not expand the height of the tr
|
||||
td.innerHTML =
|
||||
`<div class="flex items-stretch absolute top-[-4px]">
|
||||
<input type="text" class="bordered-field">
|
||||
<button type="button" class="relative -ml-px inline-flex items-center gap-x-1.5 px-3 py-1 text-gray-800 shadow-sm ring-1 ring-inset ring-gray-400 bg-white hover:bg-gray-50">${ CheckIconHTML }</button>
|
||||
</div>`;
|
||||
|
||||
td.querySelector('input')!.addEventListener('keydown', async function(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
await onLineClassified(event);
|
||||
}
|
||||
})
|
||||
td.querySelector('button')!.addEventListener('click', onLineClassified);
|
||||
|
||||
td.querySelector('input')!.focus();
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
async function onLineClassified(event: Event) {
|
||||
// Callback when clicking OK or pressing enter to classify a statement line
|
||||
if ((event.target as HTMLInputElement).disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const td = (event.target as Element).closest('td')!;
|
||||
const tr = td.closest('tr')!;
|
||||
const lineId = parseInt(tr.dataset.lineId!);
|
||||
const chargeAccount = (td.querySelector('input')! as HTMLInputElement).value;
|
||||
|
||||
if (!chargeAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable further submissions
|
||||
td.querySelector('input')!.disabled = true;
|
||||
td.querySelector('button')!.disabled = true;
|
||||
|
||||
const statementLine = statementLines.value.find((l) => l.id === lineId)!;
|
||||
|
||||
// Insert transaction and statement line reconciliation atomically
|
||||
const session = await db.load();
|
||||
const dbTransaction = await session.begin();
|
||||
|
||||
// Insert transaction
|
||||
const transactionResult = await dbTransaction.execute(
|
||||
`INSERT INTO transactions (dt, description)
|
||||
VALUES ($1, $2)`,
|
||||
[statementLine.dt, statementLine.description]
|
||||
);
|
||||
const transactionId = transactionResult.lastInsertId;
|
||||
|
||||
// Insert posting for this account
|
||||
const accountPostingResult = await dbTransaction.execute(
|
||||
`INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance)
|
||||
VALUES ($1, NULL, $2, $3, $4, NULL)`,
|
||||
[transactionId, statementLine.source_account, statementLine.quantity, statementLine.commodity]
|
||||
);
|
||||
const accountPostingId = accountPostingResult.lastInsertId;
|
||||
|
||||
// Insert posting for the charge account - no need to remember this ID
|
||||
await dbTransaction.execute(
|
||||
`INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance)
|
||||
VALUES ($1, NULL, $2, $3, $4, NULL)`,
|
||||
[transactionId, chargeAccount, -statementLine.quantity, statementLine.commodity]
|
||||
);
|
||||
|
||||
// Insert statement line reconciliation
|
||||
await dbTransaction.execute(
|
||||
`INSERT INTO statement_line_reconciliations (statement_line_id, posting_id)
|
||||
VALUES ($1, $2)`,
|
||||
[statementLine.id, accountPostingId]
|
||||
);
|
||||
|
||||
// Invalidate running balances
|
||||
await dbTransaction.execute(
|
||||
`UPDATE postings
|
||||
SET running_balance = NULL
|
||||
FROM (
|
||||
SELECT postings.id
|
||||
FROM transactions
|
||||
JOIN postings ON transactions.id = postings.transaction_id
|
||||
WHERE DATE(dt) >= DATE($1) AND account IN ($2, $3)
|
||||
) p
|
||||
WHERE postings.id = p.id`,
|
||||
[statementLine.dt, statementLine.source_account, chargeAccount]
|
||||
);
|
||||
|
||||
dbTransaction.commit();
|
||||
|
||||
// Reload transactions and re-render the table
|
||||
await load();
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
|
||||
const rows = [];
|
||||
@ -235,26 +134,23 @@
|
||||
if (line.posting_accounts.length === 0) {
|
||||
// Unreconciled
|
||||
reconciliationCell =
|
||||
`<a href="#" class="classify-link text-red-500 hover:text-red-600 hover:underline" onclick="return showClassifyLinePanel(this);">Unclassified</a>`;
|
||||
`<a href="#" class="text-red-500 hover:text-red-600 hover:underline" onclick="return classifyLine(this);">Unclassified</a>
|
||||
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
} else if (line.posting_accounts.length === 2) {
|
||||
// Simple reconciliation
|
||||
const otherAccount = line.posting_accounts.find((a) => a !== line.source_account);
|
||||
reconciliationCell =
|
||||
`<span>${ otherAccount }</span>
|
||||
<a href="/journal/edit/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
|
||||
if (showOnlyUnclassified.value) { continue; }
|
||||
`<a href="#" class="hover:text-blue-700 hover:underline" onclick="return classifyLine(this);">${ otherAccount }</a>
|
||||
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
} else {
|
||||
// Complex reconciliation
|
||||
reconciliationCell =
|
||||
`<i>(Complex)</i>
|
||||
<a href="/journal/edit/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
|
||||
if (showOnlyUnclassified.value) { continue; }
|
||||
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
}
|
||||
|
||||
rows.push(
|
||||
`<tr data-line-id="${ line.id }">
|
||||
`<tr data-line-id="{{ line.id }}">
|
||||
<td class="py-0.5 pr-1 align-baseline"><input class="checkbox-primary" type="checkbox" name="sel-line-id" value="${ line.id }"></td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900"><a href="#" class="hover:text-blue-700 hover:underline">${ line.source_account }</a></td>
|
||||
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex]">${ dayjs(line.dt).format('YYYY-MM-DD') }</td>
|
||||
@ -271,15 +167,13 @@
|
||||
clusterize = new Clusterize({
|
||||
'rows': rows,
|
||||
scrollElem: document.getElementById('statement-line-list')!,
|
||||
contentElem: document.querySelector('#statement-line-list tbody')!,
|
||||
show_no_data_row: false,
|
||||
contentElem: document.querySelector('#statement-line-list tbody')!
|
||||
});
|
||||
} else {
|
||||
clusterize.update(rows);
|
||||
}
|
||||
}
|
||||
|
||||
watch(showOnlyUnclassified, renderTable);
|
||||
watch(statementLines, renderTable);
|
||||
|
||||
load();
|
||||
|
@ -21,16 +21,14 @@
|
||||
{{ route.params.account }}
|
||||
</h1>
|
||||
|
||||
<div class="my-4 flex gap-x-2 items-center">
|
||||
<div class="my-4 flex gap-x-2">
|
||||
<!-- Use a rather than RouterLink because RouterLink adds its own event handler -->
|
||||
<a href="/journal/new-transaction" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
New transaction
|
||||
</a>
|
||||
<div class="flex items-baseline">
|
||||
<input id="only-unclassified" class="ml-3 mr-1 self-center checkbox-primary" type="checkbox" v-model="commodityDetail">
|
||||
<label for="only-unclassified" class="text-gray-900">Show commodity detail</label>
|
||||
</div>
|
||||
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
|
||||
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
|
||||
</div>
|
||||
|
||||
<TransactionsWithCommodityView v-if="commodityDetail" :transactions="transactions"/>
|
||||
|
@ -84,7 +84,7 @@
|
||||
for (const transaction of transactions) {
|
||||
let editLink = '';
|
||||
if (transaction.id !== null) {
|
||||
editLink = `<a href="/journal/edit/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
editLink = `<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
}
|
||||
rows.push(
|
||||
`<tr class="border-t border-gray-300">
|
||||
@ -140,8 +140,7 @@
|
||||
clusterize = new Clusterize({
|
||||
'rows': rows,
|
||||
scrollElem: document.getElementById('transaction-list')!,
|
||||
contentElem: document.querySelector('#transaction-list tbody')!,
|
||||
show_no_data_row: false,
|
||||
contentElem: document.querySelector('#transaction-list tbody')!
|
||||
});
|
||||
} else {
|
||||
clusterize.update(rows);
|
||||
|
@ -80,7 +80,7 @@
|
||||
for (const transaction of transactions) {
|
||||
let editLink = '';
|
||||
if (transaction.id !== null) {
|
||||
editLink = `<a href="/journal/edit/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
editLink = `<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
|
||||
}
|
||||
|
||||
if (transaction.postings.length == 2) {
|
||||
@ -139,8 +139,7 @@
|
||||
clusterize = new Clusterize({
|
||||
'rows': rows,
|
||||
scrollElem: document.getElementById('transaction-list')!,
|
||||
contentElem: document.querySelector('#transaction-list tbody')!,
|
||||
show_no_data_row: false,
|
||||
contentElem: document.querySelector('#transaction-list tbody')!
|
||||
});
|
||||
} else {
|
||||
clusterize.update(rows);
|
||||
|
@ -1,25 +0,0 @@
|
||||
/*
|
||||
DrCr: Web-based double-entry bookkeeping framework
|
||||
Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const accountKinds: [string, string][] = [
|
||||
['drcr.asset', 'Asset'],
|
||||
['drcr.liability', 'Liability'],
|
||||
['drcr.income', 'Income'],
|
||||
['drcr.expense', 'Expense'],
|
||||
['drcr.equity', 'Equity']
|
||||
];
|
@ -17,7 +17,7 @@
|
||||
*/
|
||||
|
||||
import { asCost } from './amounts.ts';
|
||||
import { JoinedTransactionPosting, StatementLine, Transaction, joinedToTransactions, totalBalances, totalBalancesAtDate } from './db.ts';
|
||||
import { JoinedTransactionPosting, StatementLine, Transaction, joinedToTransactions, totalBalances } from './db.ts';
|
||||
import { ExtendedDatabase } from './dbutil.ts';
|
||||
|
||||
export enum ReportingStage {
|
||||
@ -32,7 +32,7 @@ export class ReportingWorkflow {
|
||||
transactionsForStage: Map<ReportingStage, Transaction[]> = new Map();
|
||||
reportsForStage: Map<ReportingStage, Report[]> = new Map();
|
||||
|
||||
async generate(session: ExtendedDatabase, dt?: string) {
|
||||
async generate(session: ExtendedDatabase) {
|
||||
// ------------------------
|
||||
// TransactionsFromDatabase
|
||||
|
||||
@ -40,32 +40,16 @@ export class ReportingWorkflow {
|
||||
|
||||
{
|
||||
// Load balances from database
|
||||
if (dt) {
|
||||
balances = await totalBalancesAtDate(session, dt);
|
||||
} else {
|
||||
balances = await totalBalances(session);
|
||||
}
|
||||
this.reportsForStage.set(ReportingStage.TransactionsFromDatabase, [new TrialBalanceReport(balances)]);
|
||||
|
||||
// Load transactions from database
|
||||
let joinedTransactionPostings: JoinedTransactionPosting[];
|
||||
if (dt) {
|
||||
joinedTransactionPostings = await session.select(
|
||||
`SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity, running_balance
|
||||
FROM transactions
|
||||
JOIN postings ON transactions.id = postings.transaction_id
|
||||
WHERE DATE(dt) <= DATE($1)
|
||||
ORDER BY dt, transaction_id, postings.id`,
|
||||
[dt]
|
||||
);
|
||||
} else {
|
||||
joinedTransactionPostings = await session.select(
|
||||
const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select(
|
||||
`SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity, running_balance
|
||||
FROM transactions
|
||||
JOIN postings ON transactions.id = postings.transaction_id
|
||||
ORDER BY dt, transaction_id, postings.id`
|
||||
);
|
||||
}
|
||||
const transactions = joinedToTransactions(joinedTransactionPostings);
|
||||
this.transactionsForStage.set(ReportingStage.TransactionsFromDatabase, transactions);
|
||||
}
|
||||
@ -75,22 +59,12 @@ export class ReportingWorkflow {
|
||||
|
||||
{
|
||||
// Get unreconciled statement lines
|
||||
let unreconciledStatementLines: StatementLine[];
|
||||
if (dt) {
|
||||
unreconciledStatementLines = await session.select(
|
||||
const unreconciledStatementLines: StatementLine[] = await session.select(
|
||||
// On testing, JOIN is much faster than WHERE NOT EXISTS
|
||||
`SELECT statement_lines.* FROM statement_lines
|
||||
LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id
|
||||
WHERE statement_line_reconciliations.id IS NULL AND DATE(dt) <= DATE($1)`,
|
||||
[dt]
|
||||
);
|
||||
} else {
|
||||
unreconciledStatementLines = await session.select(
|
||||
`SELECT statement_lines.* FROM statement_lines
|
||||
LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id
|
||||
WHERE statement_line_reconciliations.id IS NULL`
|
||||
);
|
||||
}
|
||||
|
||||
const transactions = [];
|
||||
for (const line of unreconciledStatementLines) {
|
||||
|
@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
|
Loading…
Reference in New Issue
Block a user