diff --git a/README.md b/README.md
index b93472c..7795451 100644
--- a/README.md
+++ b/README.md
@@ -6,18 +6,39 @@
[](https://github.com/trakli/webui/actions/workflows/checks.yml)
-Web UI for [Trakli](https://github.com/trakli/trakli).
+Money apps want your bank login, and then they want your money to be in one country, in one currency, in
+an account they can read. Get paid from abroad, keep cash, hold two currencies at once, or move money over
+mobile money, and most of your money is invisible to them. So it ends up in a spreadsheet, which holds
+anything and tells you nothing.
+
+Trakli tracks money the way it actually moves. Cash, mobile money, bank and card sit beside each other as
+wallets, in any of forty-eight currencies, converting at the rate you say applies and not one scraped from a
+market you cannot get. Your phone keeps working with no signal and settles up when it reconnects. And it
+is yours: your server, your database, no one else holding the ledger of what you earn.
+
+This is the web app. It talks to the [webservice](https://github.com/trakli/webservice), which you will
+need running first.
## Features
+What you will not find elsewhere:
+
+- **Wallets that match reality:** cash, mobile money, bank and card, side by side.
+- **Any currency, at your rate:** forty-eight of them, held at once; transfers convert at the rate you set.
+- **An assistant that can act:** ask it in plain words, and it proposes every change for you to confirm
+ before anything is written.
+- **Offline-first:** the phone works with no signal, and changes settle cleanly when it reconnects.
+- **Yours to run:** your server, your database, no bank login handed to anyone.
+
+The rest, done properly:
+
- **Transactions:** Income and expenses across multiple wallets, with attachments and recurring rules.
- **Transfers:** Move money between wallets, including cross-currency at user-set rates.
- **Budgets:** Scoped to categories, groups, or wallets; weekly / monthly / yearly / custom range; optional rollover; threshold and forecast alerts.
- **Refunds:** Mark an income as refunding an earlier expense; matching budgets adjust automatically.
- **Reminders:** Bills, budget alerts, and custom events with pause, resume, and snooze.
- **Imports:** Pull transactions from CSVs, PDFs, and photos of receipts.
-- **Insights & AI:** Dashboard stats, digest emails, and a chat assistant for your finances.
-- **Offline-first:** Changes made on mobile sync cleanly when the device reconnects.
+- **Insights:** Dashboard stats and digest emails.
## Setup Instructions
diff --git a/components/TransactionForm.vue b/components/TransactionForm.vue
index 9812cf2..6456a08 100644
--- a/components/TransactionForm.vue
+++ b/components/TransactionForm.vue
@@ -120,11 +120,9 @@
@@ -290,6 +288,7 @@ import {
Gift
} from 'lucide-vue-next';
import { useSharedData } from '~/composables/useSharedData';
+import { CURRENCIES } from '@/utils/currencies';
import { fetchAllPages } from '~/services/api/apiHelpers';
import { api } from '@/services/api';
import type { TransactionFile, TransactionIntent } from '~/types/transaction';
@@ -344,7 +343,7 @@ const intentOptions = computed(() => {
const selectedPartyId = ref(null);
const selectedWalletId = ref(null);
const selectedGroupId = ref(null);
-const selectedAdditionalCategoryIds = ref([]);
+const selectedCategoryId = ref(null);
type NewAttachment = {
file: File;
name: string;
@@ -425,7 +424,7 @@ function onSubmit() {
partyId: selectedPartyId.value,
amount: `${amountNum} ${selectedCurrency.value}`,
category: formCategory.value,
- categoryIds: selectedAdditionalCategoryIds.value,
+ categoryIds: selectedCategoryId.value ? [selectedCategoryId.value] : [],
groupId: selectedGroupId.value ?? undefined,
walletId: selectedWalletId.value,
description: formDescription.value.trim(),
@@ -461,7 +460,9 @@ const categories = computed(() => {
});
const availableCurrencies = computed(() => {
- const currencies = new Set(['XAF', 'USD', 'EUR', 'GBP', 'NGN']);
+ const currencies = new Set(CURRENCIES.map((c) => c.code));
+ // A wallet may hold a currency the list does not carry; never hide it from
+ // the person whose money is already in it.
sharedData.wallets.value.forEach((wallet) => {
if (wallet.currency) {
currencies.add(wallet.currency);
@@ -543,8 +544,10 @@ const groupSearchQuery = ref('');
const categorySearchQuery = ref('');
const walletSearchQuery = ref('');
-function handleCategorySelect(categoryIds) {
- selectedAdditionalCategoryIds.value = categoryIds;
+// A transaction holds one category, so the dropdown is single-select and hands
+// back the chosen option rather than a list of ids.
+function handleCategorySelect(category) {
+ selectedCategoryId.value = category?.id ?? null;
}
async function loadRecentExpenses() {
@@ -758,8 +761,14 @@ watch(
}
}
+ // Transactions recorded before categories were limited to one may still
+ // carry several; the first is kept and the rest drop away on save.
if (item.categoryIds && item.categoryIds.length > 0) {
- selectedAdditionalCategoryIds.value = item.categoryIds;
+ selectedCategoryId.value = item.categoryIds[0];
+ const category = categories.value.find((c) => c.id === selectedCategoryId.value);
+ if (category) {
+ categorySearchQuery.value = category.name;
+ }
}
if (item.walletId) {
diff --git a/components/TransferForm.vue b/components/TransferForm.vue
index 1a34ebf..e8fc096 100644
--- a/components/TransferForm.vue
+++ b/components/TransferForm.vue
@@ -105,6 +105,7 @@ import TButton from './TButton.vue';
import SearchableDropdown from './SearchableDropdown.vue';
import { ArrowsRightLeftIcon } from '@heroicons/vue/24/outline';
import { useSharedData } from '~/composables/useSharedData';
+import { CURRENCIES } from '@/utils/currencies';
const { t } = useI18n();
@@ -138,7 +139,9 @@ const toWalletError = ref(false);
const exchangeRateError = ref(false);
const availableCurrencies = computed(() => {
- const currencies = new Set(['XAF', 'USD', 'EUR', 'GBP', 'NGN']);
+ const currencies = new Set(CURRENCIES.map((c) => c.code));
+ // A wallet may hold a currency the list does not carry; never hide it from
+ // the person whose money is already in it.
sharedData.wallets.value.forEach((wallet) => {
if (wallet.currency) {
currencies.add(wallet.currency);
diff --git a/components/WalletForm.vue b/components/WalletForm.vue
index 427a228..2617c6c 100644
--- a/components/WalletForm.vue
+++ b/components/WalletForm.vue
@@ -75,15 +75,9 @@
required
>
-
-
-
-
-
-
-
-
-
+
{{ t('Please select a currency.') }}
@@ -134,6 +128,7 @@ import IconPicker from './IconPicker.vue';
import * as lucideIcons from 'lucide-vue-next';
import { ImagePlus, X } from 'lucide-vue-next';
import { useSharedData } from '@/composables/useSharedData';
+import { CURRENCIES } from '@/utils/currencies';
const { t } = useI18n();
diff --git a/components/ai/ChatResultRenderer.vue b/components/ai/ChatResultRenderer.vue
index a525d58..8ac3c2f 100644
--- a/components/ai/ChatResultRenderer.vue
+++ b/components/ai/ChatResultRenderer.vue
@@ -1,7 +1,7 @@
-
+
+ (() => props.result?.blocks ?? []);
const sessionId = computed(() => props.sessionId ?? 0);
+/**
+ * Key an action card by its own identity, not its position: a reload that
+ * reorders blocks would otherwise hand a card the wrong reactive state.
+ */
+const blockKey = (block: ChatBlock, index: number): string => {
+ if (block.type === 'proposed_action' && block.id) return `action-${block.id}`;
+ if (block.type === 'proposed_action_batch' && block.batch) return `batch-${block.batch}`;
+ return `${block.type}-${index}`;
+};
+
const formatKey = (key: string | number): string =>
String(key)
.replace(/_/g, ' ')
diff --git a/components/ai/blocks/ChatActionFields.vue b/components/ai/blocks/ChatActionFields.vue
new file mode 100644
index 0000000..3a37bd3
--- /dev/null
+++ b/components/ai/blocks/ChatActionFields.vue
@@ -0,0 +1,143 @@
+
+