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
33 changes: 33 additions & 0 deletions assets/scss/_surfaces.scss
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@
--surface-accent: var(--color-border-medium);
--surface-deep: var(--color-text-secondary);
}
&--investment {
--surface-bg: rgba(14, 165, 233, 0.12);
--surface-ink: var(--color-text-primary);
--surface-accent: rgba(14, 165, 233, 0.5);
--surface-deep: #0284c7;
}
&--loan {
--surface-bg: rgba(217, 119, 6, 0.12);
--surface-ink: var(--color-text-primary);
--surface-accent: rgba(217, 119, 6, 0.5);
--surface-deep: #b45309;
}
&--gift {
--surface-bg: rgba(124, 58, 237, 0.12);
--surface-ink: var(--color-text-primary);
--surface-accent: rgba(124, 58, 237, 0.5);
--surface-deep: #6d28d9;
}
}

// In dark mode the surface bg becomes a subtle tint so it sits on the page
Expand Down Expand Up @@ -65,6 +83,21 @@
--surface-accent: var(--color-border-medium);
--surface-deep: var(--color-text-secondary);
}
.surface--investment {
--surface-bg: rgba(14, 165, 233, 0.16);
--surface-accent: rgba(56, 189, 248, 0.5);
--surface-deep: #38bdf8;
}
.surface--loan {
--surface-bg: rgba(217, 119, 6, 0.16);
--surface-accent: rgba(251, 191, 36, 0.5);
--surface-deep: #fbbf24;
}
.surface--gift {
--surface-bg: rgba(124, 58, 237, 0.16);
--surface-accent: rgba(167, 139, 250, 0.5);
--surface-deep: #a78bfa;
}
}

.display-1 {
Expand Down
44 changes: 44 additions & 0 deletions assets/scss/_transaction-form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,50 @@
font-size: $font-size-sm;
}

.intent-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

.intent-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid $border-color;
border-radius: 999px;
background: $bg-white;
color: $text-secondary;
font-size: $font-size-sm;
font-weight: $font-medium;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;

&:hover {
border-color: $primary;
color: $text-primary;
}

.intent-pill-icon {
width: 16px;
height: 16px;
}
}

.intent-pill--active {
background: $primary;
border-color: $primary;
color: white;

&:hover {
color: white;
}
}

.wallet-field-wrapper,
.group-field-wrapper {
position: relative;
Expand Down
88 changes: 55 additions & 33 deletions components/TSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
}"
>
<div class="sidebar-header">
<NuxtLink to="/home" class="sidebar-brand" :aria-label="t('Home')">
<NuxtLink to="/dashboard" class="sidebar-brand" :aria-label="t('Home')">
<img v-if="isCompact" src="/logo-mark.svg" alt="Trakli" class="sidebar-brand__mark" />
<Logo v-else size="small" alt="Trakli Logo" />
</NuxtLink>
Expand All @@ -33,10 +33,11 @@
class="nav-button"
:class="{
'nav-button--with-subtext': !isCompact && item.hint,
'nav-button--compact': isCompact
'nav-button--compact': isCompact,
selected: subPathActive(item)
}"
active-class="selected"
:title="isCompact ? `${t(item.label)} ${t(item.hint ?? '')}` : undefined"
:title="isCompact ? `${t(item.label)}: ${t(item.hint ?? '')}` : undefined"
@click="handleNavClick"
>
<component :is="item.icon" class="icon" />
Expand All @@ -48,10 +49,13 @@
<span v-else class="compact-label">{{ t(item.label) }}</span>
</NuxtLink>

<ul v-if="!isCompact && item.subItems && isActive(item.to)" class="sub-list">
<ul
v-if="!isCompact && item.subItems && (isActive(item.to) || subPathActive(item))"
class="sub-list"
>
<li v-for="sub in item.subItems" :key="sub.label">
<NuxtLink
:to="sub.query ? { path: item.to, query: sub.query } : item.to"
:to="sub.to ? sub.to : sub.query ? { path: item.to, query: sub.query } : item.to"
class="sub-link"
:class="{ selected: isSubActive(item.to, sub) }"
@click="handleNavClick"
Expand Down Expand Up @@ -119,7 +123,7 @@ import {
WalletIcon,
XMarkIcon
} from '@heroicons/vue/24/outline';
import { Bot, Sparkles } from 'lucide-vue-next';
import { Sparkles, Scale, Coins } from 'lucide-vue-next';
import { computed } from 'vue';
import Logo from './Logo.vue';
import { useSidebar } from '@/composables/useSidebar';
Expand All @@ -141,42 +145,48 @@ const route = useRoute();
const isCompact = computed(() => desktopCollapsed.value && !isMobile.value);

const primary = [
{ to: '/home', label: 'Ask', hint: 'Your AI chat home', icon: Bot },
{
to: '/dashboard',
label: 'Dashboard',
hint: 'Overview of your finances',
icon: Squares2X2Icon
},
{
to: '/home',
label: 'Assistant',
hint: 'Chat and ask about your finances',
icon: Sparkles,
subItems: [{ label: 'Discussions', to: '/assistant', tone: 'info' }]
},
{
to: '/transactions',
label: 'Transactions',
hint: 'All money movements in one place',
icon: RectangleGroupIcon
},
{
to: '/categories',
label: 'Categories',
hint: 'Organize transactions by type',
icon: TagIcon
to: '/wallets',
label: 'Wallets',
hint: 'Accounts money moves through',
icon: WalletIcon
},
{
to: '/groups',
label: 'Groups',
hint: 'Bundle related transactions',
icon: UserGroupIcon
to: '/financial-position',
label: 'Financial position',
hint: 'Net worth, grouped by intent',
icon: Scale
},
{
to: '/parties',
label: 'Parties',
hint: 'People and organizations you transact with',
icon: UsersIcon
to: '/holdings',
label: 'Holdings',
hint: 'Assets you own: crypto, stocks, property',
icon: Coins
},
{
to: '/wallets',
label: 'Wallets',
hint: 'Accounts money moves through',
icon: WalletIcon
to: '/reports',
label: 'Reports',
hint: 'Charts and insights',
icon: ChartBarIcon
},
{
to: '/budgets',
Expand All @@ -190,28 +200,40 @@ const primary = [
]
},
{
to: '/reminders',
label: 'Reminders',
hint: 'Track upcoming or expected transactions',
icon: BellIcon
to: '/categories',
label: 'Categories',
hint: 'Organize transactions by type',
icon: TagIcon
},
{
to: '/imports',
label: 'Import',
hint: 'Import transactions from documents',
icon: ArrowUpTrayIcon
to: '/parties',
label: 'Parties',
hint: 'People and organizations you transact with',
icon: UsersIcon
},
{
to: '/groups',
label: 'Groups',
hint: 'Bundle related transactions',
icon: UserGroupIcon
}
];

const footer = [
{ to: '/assistant', label: 'Assistant', icon: Sparkles },
{ to: '/reports', label: 'Reports', icon: ChartBarIcon },
{ to: '/reminders', label: 'Reminders', icon: BellIcon },
{ to: '/imports', label: 'Import', icon: ArrowUpTrayIcon },
{ to: '/settings', label: 'Settings', icon: Cog8ToothIcon }
];

const isActive = (path) => route.path === path || route.path.startsWith(path + '/');

// True when one of the item's sub-items navigates to its own route (e.g. the
// Assistant "Discussions" sub points at /assistant), so the parent stays lit
// and its sub-list stays open while on that route.
const subPathActive = (item) => (item.subItems ?? []).some((sub) => sub.to && isActive(sub.to));

const isSubActive = (parentTo, sub) => {
if (sub.to) return isActive(sub.to);
if (route.path !== parentTo) return false;
if (!sub.query) return !route.query.scope;
const key = Object.keys(sub.query)[0];
Expand Down
59 changes: 58 additions & 1 deletion components/TransactionForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,27 @@
<textarea v-model="formDescription" :placeholder="t('Type here...')" />
</div>

<div class="transaction-date">
<span
>{{ t('Intent') }} <span class="optional-label">({{ t('optional') }})</span></span
>
<div class="intent-pills" role="radiogroup" :aria-label="t('Intent')">
<button
v-for="opt in intentOptions"
:key="opt.value"
type="button"
class="intent-pill"
:class="{ 'intent-pill--active': formIntent === opt.value }"
role="radio"
:aria-checked="formIntent === opt.value"
@click="formIntent = opt.value"
>
<component :is="intentIcons[opt.value]" class="intent-pill-icon" />
<span>{{ t(opt.label) }}</span>
</button>
</div>
</div>

<div class="form-transaction">
<div class="transaction-date">
<span>{{ t('Transaction date') }}</span>
Expand Down Expand Up @@ -258,10 +279,21 @@ import { toRefs, ref, computed, watch, onMounted, onUnmounted } from 'vue';
import TButton from './TButton.vue';
import SearchableDropdown from './SearchableDropdown.vue';
import { CheckIcon, PencilIcon } from '@heroicons/vue/24/outline';
import {
Circle,
HandCoins,
Banknote,
Scale,
Handshake,
TrendingUp,
PiggyBank,
Gift
} from 'lucide-vue-next';
import { useSharedData } from '~/composables/useSharedData';
import { fetchAllPages } from '~/services/api/apiHelpers';
import { api } from '@/services/api';
import type { TransactionFile } from '~/types/transaction';
import type { TransactionFile, TransactionIntent } from '~/types/transaction';
import { TRANSACTION_INTENT_OPTIONS } from '~/types/transaction';

const { t } = useI18n();

Expand Down Expand Up @@ -291,6 +323,23 @@ const formAmount = ref('');
const formParty = ref('');
const formCategory = ref('');
const formDescription = ref('');
const formIntent = ref<TransactionIntent>('regular');

const intentIcons: Record<TransactionIntent, unknown> = {
regular: Circle,
loan_received: HandCoins,
loan_repayment: Banknote,
debt_owed: Scale,
debt_settled: Handshake,
investment_buy: TrendingUp,
investment_return: PiggyBank,
gift: Gift
};

const intentOptions = computed(() => {
const side = isOutcomeSelected.value ? 'expense' : 'income';
return TRANSACTION_INTENT_OPTIONS.filter((o) => o.side === 'both' || o.side === side);
});

const selectedPartyId = ref<number | null>(null);
const selectedWalletId = ref<number | null>(null);
Expand Down Expand Up @@ -371,6 +420,7 @@ function onSubmit() {
date,
time,
type: isOutcomeSelected.value ? 'EXPENSE' : 'INCOME',
intent: formIntent.value,
party: formParty.value,
partyId: selectedPartyId.value,
amount: `${amountNum} ${selectedCurrency.value}`,
Expand Down Expand Up @@ -550,6 +600,11 @@ watch(isOutcomeSelected, (isExpense) => {
formIsRefund.value = false;
formRefundOfTransactionId.value = null;
}
// Drop an intent that no longer applies to the selected side (e.g. an
// income-only intent after switching to expense).
if (!intentOptions.value.some((o) => o.value === formIntent.value)) {
formIntent.value = 'regular';
}
});

onMounted(async () => {
Expand Down Expand Up @@ -722,6 +777,8 @@ watch(
formDescription.value = item.description;
}

formIntent.value = item.intent || 'regular';

if (item.amount) {
const num = parseFloat(String(item.amount).replace(/[^\d.]/g, ''));
formAmount.value = Number.isFinite(num) ? String(num) : '';
Expand Down
Loading
Loading