Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 18 additions & 9 deletions components/TransactionForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,9 @@

<SearchableDropdown
v-model="categorySearchQuery"
:label="t('Categories')"
:label="t('Category')"
:placeholder="t('Search categories...')"
:options="categories"
:multiple="true"
:selected="selectedAdditionalCategoryIds"
@select="handleCategorySelect"
/>
</div>
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -344,7 +343,7 @@ const intentOptions = computed(() => {
const selectedPartyId = ref<number | null>(null);
const selectedWalletId = ref<number | null>(null);
const selectedGroupId = ref(null);
const selectedAdditionalCategoryIds = ref([]);
const selectedCategoryId = ref<number | null>(null);
type NewAttachment = {
file: File;
name: string;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion components/TransferForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down
13 changes: 4 additions & 9 deletions components/WalletForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,9 @@
required
>
<option value="">{{ t('Select currency') }}</option>
<option value="XAF">XAF - {{ t('Central African Franc') }}</option>
<option value="USD">USD - {{ t('US Dollar') }}</option>
<option value="EUR">EUR - {{ t('Euro') }}</option>
<option value="GBP">GBP - {{ t('British Pound') }}</option>
<option value="JPY">JPY - {{ t('Japanese Yen') }}</option>
<option value="CAD">CAD - {{ t('Canadian Dollar') }}</option>
<option value="AUD">AUD - {{ t('Australian Dollar') }}</option>
<option value="CHF">CHF - {{ t('Swiss Franc') }}</option>
<option value="CNY">CNY - {{ t('Chinese Yuan') }}</option>
<option v-for="currency in CURRENCIES" :key="currency.code" :value="currency.code">
{{ currency.code }} - {{ currency.name }}
</option>
</select>
<div v-if="currencyError" class="error-text">{{ t('Please select a currency.') }}</div>
</div>
Expand Down Expand Up @@ -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();

Expand Down
19 changes: 18 additions & 1 deletion components/ai/ChatResultRenderer.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<!-- New agent path: an ordered stream of widget blocks. -->
<div v-if="blocks.length" class="blocks-container">
<template v-for="(block, i) in blocks" :key="i">
<template v-for="(block, i) in blocks" :key="blockKey(block, i)">
<ChatMarkdownBlock v-if="block.type === 'markdown'" :text="block.text as string" />
<ChatTableBlock
v-else-if="block.type === 'table'"
Expand All @@ -27,6 +27,12 @@
:session-id="sessionId"
@changed="$emit('changed')"
/>
<ChatProposedActionBatchBlock
v-else-if="block.type === 'proposed_action_batch'"
:block="block as any"
:session-id="sessionId"
@changed="$emit('changed')"
/>
<ChatComparisonBlock
v-else-if="block.type === 'comparison'"
:title="block.title as string"
Expand Down Expand Up @@ -141,6 +147,7 @@ import ChatTableBlock from '@/components/ai/blocks/ChatTableBlock.vue';
import ChatKpiBlock from '@/components/ai/blocks/ChatKpiBlock.vue';
import ChatChartBlock from '@/components/ai/blocks/ChatChartBlock.vue';
import ChatProposedActionBlock from '@/components/ai/blocks/ChatProposedActionBlock.vue';
import ChatProposedActionBatchBlock from '@/components/ai/blocks/ChatProposedActionBatchBlock.vue';
import ChatImportReviewBlock from '@/components/ai/blocks/ChatImportReviewBlock.vue';
import ChatCanvasBlock from '@/components/ai/blocks/ChatCanvasBlock.vue';
import ChatComparisonBlock from '@/components/ai/blocks/ChatComparisonBlock.vue';
Expand All @@ -165,6 +172,16 @@ defineEmits<{
const blocks = computed<ChatBlock[]>(() => 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, ' ')
Expand Down
143 changes: 143 additions & 0 deletions components/ai/blocks/ChatActionFields.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<template>
<div class="af">
<label v-for="f in editable" :key="f.key" class="af-row">
<span class="af-label">{{ f.label }}</span>

<select v-if="f.type === 'enum'" v-model="draft[f.key]" class="af-input">
<option v-for="opt in f.options || []" :key="opt" :value="opt">{{ cap(opt) }}</option>
</select>

<select v-else-if="f.type === 'wallet'" v-model="draft[f.key]" class="af-input">
<option v-for="w in wallets" :key="w.id" :value="w.id">{{ w.name }}</option>
</select>

<select v-else-if="f.type === 'party'" v-model="draft[f.key]" class="af-input">
<option :value="null">{{ t('None') }}</option>
<option v-for="p in parties" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>

<!-- One category per transaction, so this is a plain single select. -->
<select v-else-if="f.type === 'category'" v-model="draft[f.key]" class="af-input">
<option :value="null">{{ t('None') }}</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>

<input
v-else-if="f.type === 'number'"
v-model.number="draft[f.key]"
type="number"
step="0.01"
class="af-input"
/>
<input
v-else-if="f.type === 'datetime'"
v-model="draft[f.key]"
type="datetime-local"
class="af-input"
/>
<input v-else v-model="draft[f.key]" type="text" class="af-input" />
</label>
</div>
</template>

<script setup lang="ts">
import { computed, reactive, onMounted, watch } from 'vue';
import type { ProposedActionField } from '@/services/api/aiApi';
import { useSharedData } from '@/composables/useSharedData';

const props = defineProps<{ fields: ProposedActionField[] }>();
const emit = defineEmits<{ (e: 'change', overrides: Record<string, unknown>): void }>();

const { t } = useI18n();
const sharedData = useSharedData();
const wallets = sharedData.wallets;
const categories = sharedData.categories;
const parties = sharedData.parties;

// A readonly row is context for the reader, not something to send back.
const editable = computed(() => props.fields.filter((f) => f.type !== 'readonly'));

const draft = reactive<Record<string, unknown>>({});

const nowLocal = () => {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
};

onMounted(() => {
editable.value.forEach((f) => {
if (f.type === 'datetime') {
draft[f.key] = typeof f.value === 'string' && f.value ? f.value.slice(0, 16) : nowLocal();
return;
}
draft[f.key] = f.value;
});

if (editable.value.some((f) => f.type === 'wallet')) sharedData.loadWallets?.();
if (editable.value.some((f) => f.type === 'category')) sharedData.loadCategories?.();
if (editable.value.some((f) => f.type === 'party')) sharedData.loadParties?.();
});

const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);

watch(
draft,
() => {
const out: Record<string, unknown> = {};
editable.value.forEach((f) => {
const value = draft[f.key];
// Blank means "untouched", not "clear it": sending an empty datetime would
// be recorded as the Unix epoch rather than defaulted server-side.
if (value === '' || value === null || value === undefined) return;
// The payload carries one category as a list, while the control is single.
out[f.key] = f.key === 'categories' ? [value] : value;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check for wrapping the category value in an array should rely on the field's type rather than the specific key name. The AI API might use various keys (e.g., category_id, categoryIds) for fields of type category, and all of them need to be wrapped as a single-item list for the current backend implementation.

Suggested change
out[f.key] = f.key === 'categories' ? [value] : value;
out[f.key] = f.type === 'category' ? [value] : value;

});
emit('change', out);
},
{ deep: true, immediate: false }
);
</script>

<style lang="scss" scoped>
@use '@/assets/scss/_variables.scss' as *;

.af {
display: flex;
flex-direction: column;
gap: $spacing-2;
}

.af-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-3;
}

.af-label {
color: $text-muted;
font-size: $font-size-xs;
flex-shrink: 0;
}

.af-input {
flex: 1;
min-width: 0;
max-width: 62%;
padding: 5px 8px;
border: 1px solid $border-light;
border-radius: 8px;
background: $bg-white;
color: $text-primary;
font-size: $font-size-xs;
font-family: inherit;
transition: border-color 0.15s ease;

&:focus {
outline: none;
border-color: $primary;
box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.14);
}
}
</style>
Loading
Loading