diff --git a/README.md b/README.md index b93472c..7795451 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,39 @@ [![CI](https://github.com/trakli/webui/actions/workflows/checks.yml/badge.svg?branch=main)](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 @@