2 Сделать отображение транзакций на фронте

This commit is contained in:
2026-05-17 18:36:14 +03:00
parent a4f9bb63aa
commit baef5a0af2
7 changed files with 309 additions and 136 deletions
+1
View File
@@ -0,0 +1 @@
25.8.2
+6 -1
View File
@@ -23,5 +23,10 @@
"overrides": { "overrides": {
"vite": "6.3.5" "vite": "6.3.5"
} }
} },
"engines": {
"node": "^25.5.2",
"npm": ">=10"
},
"packageManager": "npm@11.11.1"
} }
+1
View File
@@ -57,6 +57,7 @@ onMounted(() => {
<template> <template>
<FinanceScreen <FinanceScreen
v-if="activeScreen === 'finance'" v-if="activeScreen === 'finance'"
:family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
@navigate="handleNavigate" @navigate="handleNavigate"
/> />
+43
View File
@@ -0,0 +1,43 @@
export interface Transaction {
id: number
family_id: number
description: string | null
type: string
datetime: string
category: string
amount: number
created_at: string
created_by: number
receipt_id: number | null
}
interface TransactionsResponse {
items: Transaction[]
}
interface GetTransactionsOptions {
familyId?: number
limit?: number
offset?: number
}
export async function getTransactions(options: GetTransactionsOptions = {}): Promise<Transaction[]> {
const params = new URLSearchParams()
if (typeof options.familyId === 'number' && Number.isFinite(options.familyId) && options.familyId > 0) {
params.set('family_id', String(options.familyId))
}
params.set('limit', String(options.limit ?? 100))
params.set('offset', String(options.offset ?? 0))
const query = params.toString()
const response = await fetch(`/api/v1/transactions${query ? `?${query}` : ''}`)
if (!response.ok) {
throw new Error(`Failed to fetch transactions: ${response.status}`)
}
const payload = await response.json() as TransactionsResponse
return Array.isArray(payload.items) ? payload.items : []
}
+6 -1
View File
@@ -13,6 +13,11 @@ type Tab = 'transactions' | 'analytics' | 'categories';
const emit = defineEmits<{ const emit = defineEmits<{
navigate: [screen: string]; navigate: [screen: string];
}>(); }>();
defineProps<{
familyId?: number;
}>();
const { t } = useI18n(); const { t } = useI18n();
const activeTab = ref<Tab>('transactions'); const activeTab = ref<Tab>('transactions');
@@ -76,7 +81,7 @@ const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
</div> </div>
</div> </div>
<TransactionsList v-if="activeTab === 'transactions'" /> <TransactionsList v-if="activeTab === 'transactions'" :family-id="familyId" />
<AnalyticsView v-else-if="activeTab === 'analytics'" /> <AnalyticsView v-else-if="activeTab === 'analytics'" />
<CategoriesView v-else /> <CategoriesView v-else />
</main> </main>
+221 -111
View File
@@ -1,21 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Component } from 'vue'; import { computed, ref, watch, type Component } from 'vue';
import { import {
Car, Car,
Coffee, Coffee,
CircleDollarSign,
Film, Film,
Heart, Heart,
Home, Home,
Receipt,
ShoppingBag, ShoppingBag,
TrendingUp, TrendingUp,
Utensils, Utensils,
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import { getTransactions, type Transaction as ApiTransaction } from '../api/transactions';
interface Transaction { interface TransactionViewModel {
id: string; id: number;
title: string; title: string;
categoryKey: string; categoryLabel: string;
amount: number; amount: number;
type: 'income' | 'expense'; type: 'income' | 'expense';
icon: Component; icon: Component;
@@ -24,12 +27,17 @@ interface Transaction {
} }
interface TransactionGroup { interface TransactionGroup {
dateKey: string; id: string;
label: string;
total: number; total: number;
transactions: Transaction[]; transactions: TransactionViewModel[];
} }
const { t } = useI18n(); const props = defineProps<{
familyId?: number;
}>();
const { locale, t } = useI18n();
const colorMap = { const colorMap = {
emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' }, emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' },
@@ -42,115 +50,216 @@ const colorMap = {
indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400' }, indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400' },
} as const; } as const;
const transactionGroups: TransactionGroup[] = [ const transactions = ref<ApiTransaction[]>([]);
{ const isLoading = ref(false);
dateKey: 'finance.transactions.today', const errorMessage = ref('');
total: -245.5,
transactions: [ const categoryPresentationMap: Record<string, { icon: Component; color: keyof typeof colorMap; labelKey?: string }> = {
{ groceries: { icon: ShoppingBag, color: 'emerald', labelKey: 'finance.category.groceries' },
id: '1', shopping: { icon: ShoppingBag, color: 'emerald' },
title: 'Whole Foods Market', food: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
categoryKey: 'finance.category.groceries', food_dining: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
amount: -124.5, dining: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
type: 'expense', coffee: { icon: Coffee, color: 'amber', labelKey: 'finance.category.coffee' },
icon: ShoppingBag, income: { icon: TrendingUp, color: 'blue', labelKey: 'finance.category.income' },
color: 'emerald', transport: { icon: Car, color: 'red', labelKey: 'finance.category.transport' },
time: '2:30 PM', entertainment: { icon: Film, color: 'purple', labelKey: 'finance.category.entertainment' },
donation: { icon: Heart, color: 'pink', labelKey: 'finance.category.donation' },
housing: { icon: Home, color: 'indigo', labelKey: 'finance.category.housing' },
rent: { icon: Home, color: 'indigo' },
receipt: { icon: Receipt, color: 'blue' },
};
const intlLocale = computed(() => (locale.value === 'ru' ? 'ru-RU' : 'en-US'));
const currencyFormatter = computed(() => new Intl.NumberFormat(intlLocale.value, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}));
const transactionGroups = computed<TransactionGroup[]>(() => {
const groups = new Map<string, TransactionGroup>();
for (const transaction of transactions.value) {
const date = new Date(transaction.datetime);
const groupId = getGroupId(date);
const signedAmount = getSignedAmount(transaction);
if (!groups.has(groupId)) {
groups.set(groupId, {
id: groupId,
label: formatGroupLabel(date),
total: 0,
transactions: [],
});
}
const group = groups.get(groupId);
if (!group) {
continue;
}
const presentation = getCategoryPresentation(transaction.category, transaction.type);
group.total += signedAmount;
group.transactions.push({
id: transaction.id,
title: getTransactionTitle(transaction),
categoryLabel: getCategoryLabel(transaction.category),
amount: transaction.amount,
type: transaction.type === 'income' ? 'income' : 'expense',
icon: presentation.icon,
color: presentation.color,
time: formatTime(date),
});
}
return Array.from(groups.values());
});
async function loadTransactions() {
isLoading.value = true;
errorMessage.value = '';
try {
transactions.value = await getTransactions({ familyId: props.familyId });
} catch (error) {
console.error('Failed to load transactions', error);
errorMessage.value = t('finance.transactions.error');
transactions.value = [];
} finally {
isLoading.value = false;
}
}
function getSignedAmount(transaction: ApiTransaction): number {
return transaction.type === 'income' ? transaction.amount : -transaction.amount;
}
function getCategoryPresentation(category: string, type: string) {
const normalizedCategory = normalizeKey(category);
if (type === 'income') {
return categoryPresentationMap.income;
}
return categoryPresentationMap[normalizedCategory] ?? { icon: CircleDollarSign, color: 'blue' as const };
}
function getCategoryLabel(category: string): string {
const presentation = categoryPresentationMap[normalizeKey(category)];
if (presentation?.labelKey) {
return t(presentation.labelKey);
}
return humanizeCategory(category);
}
function getTransactionTitle(transaction: ApiTransaction): string {
const description = transaction.description?.trim();
if (description) {
return description;
}
return getCategoryLabel(transaction.category) || t('finance.transactions.untitled');
}
function getGroupId(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatGroupLabel(date: Date): string {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (isSameDate(date, today)) {
return t('finance.transactions.today');
}
if (isSameDate(date, yesterday)) {
return t('finance.transactions.yesterday');
}
const options: Intl.DateTimeFormatOptions = date.getFullYear() === today.getFullYear()
? { day: 'numeric', month: 'short' }
: { day: 'numeric', month: 'short', year: 'numeric' };
return new Intl.DateTimeFormat(intlLocale.value, options).format(date);
}
function formatTime(date: Date): string {
return new Intl.DateTimeFormat(intlLocale.value, {
hour: 'numeric',
minute: '2-digit',
}).format(date);
}
function formatAmount(amount: number): string {
return currencyFormatter.value.format(Math.abs(amount));
}
function isSameDate(left: Date, right: Date): boolean {
return left.getFullYear() === right.getFullYear()
&& left.getMonth() === right.getMonth()
&& left.getDate() === right.getDate();
}
function normalizeKey(value: string): string {
return value.trim().toLowerCase().replace(/[\s-]+/g, '_');
}
function humanizeCategory(value: string): string {
return value
.trim()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
watch(
() => props.familyId,
() => {
void loadTransactions();
}, },
{ { immediate: true },
id: '2', );
title: 'Uber Eats',
categoryKey: 'finance.category.foodDining',
amount: -45,
type: 'expense',
icon: Utensils,
color: 'orange',
time: '12:15 PM',
},
{
id: '3',
title: 'Starbucks',
categoryKey: 'finance.category.coffee',
amount: -12.5,
type: 'expense',
icon: Coffee,
color: 'amber',
time: '9:00 AM',
},
{
id: '4',
title: 'Freelance Payment',
categoryKey: 'finance.category.income',
amount: 850,
type: 'income',
icon: TrendingUp,
color: 'blue',
time: '8:00 AM',
},
],
},
{
dateKey: 'finance.transactions.yesterday',
total: -89.99,
transactions: [
{
id: '5',
title: 'Shell Gas Station',
categoryKey: 'finance.category.transport',
amount: -65,
type: 'expense',
icon: Car,
color: 'red',
time: '6:45 PM',
},
{
id: '6',
title: 'Netflix Subscription',
categoryKey: 'finance.category.entertainment',
amount: -15.99,
type: 'expense',
icon: Film,
color: 'purple',
time: '12:00 PM',
},
{
id: '7',
title: 'Charity Donation',
categoryKey: 'finance.category.donation',
amount: -25,
type: 'expense',
icon: Heart,
color: 'pink',
time: '10:30 AM',
},
],
},
{
dateKey: 'finance.transactions.apr1',
total: -1250,
transactions: [
{
id: '8',
title: 'Rent Payment',
categoryKey: 'finance.category.housing',
amount: -1250,
type: 'expense',
icon: Home,
color: 'indigo',
time: '9:00 AM',
},
],
},
];
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<div v-for="group in transactionGroups" :key="group.dateKey"> <div
v-if="isLoading"
class="rounded-[16px] border border-white/[0.06] bg-[#16161F] px-4 py-6 text-center text-[14px] text-zinc-400"
>
{{ t('finance.transactions.loading') }}
</div>
<div
v-else-if="errorMessage"
class="rounded-[16px] border border-rose-500/20 bg-rose-500/10 px-4 py-6 text-center text-[14px] text-rose-200"
>
{{ errorMessage }}
</div>
<div
v-else-if="transactionGroups.length === 0"
class="rounded-[16px] border border-white/[0.06] bg-[#16161F] px-4 py-6 text-center text-[14px] text-zinc-400"
>
{{ t('finance.transactions.empty') }}
</div>
<template v-else>
<div v-for="group in transactionGroups" :key="group.id">
<div class="mb-3 flex items-center justify-between px-1"> <div class="mb-3 flex items-center justify-between px-1">
<h3 class="text-[14px] font-semibold text-white">{{ t(group.dateKey) }}</h3> <h3 class="text-[14px] font-semibold text-white">{{ group.label }}</h3>
<span :class="['text-[13px] font-semibold', group.total >= 0 ? 'text-emerald-400' : 'text-zinc-400']"> <span :class="['text-[13px] font-semibold', group.total >= 0 ? 'text-emerald-400' : 'text-zinc-400']">
{{ group.total >= 0 ? '+' : '' }}${{ Math.abs(group.total).toFixed(2) }} {{ group.total >= 0 ? '+' : '-' }}{{ formatAmount(group.total) }}
</span> </span>
</div> </div>
@@ -167,7 +276,7 @@ const transactionGroups: TransactionGroup[] = [
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="mb-0.5 truncate text-[14px] font-semibold text-white">{{ transaction.title }}</p> <p class="mb-0.5 truncate text-[14px] font-semibold text-white">{{ transaction.title }}</p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-[12px] text-zinc-500">{{ t(transaction.categoryKey) }}</span> <span class="text-[12px] text-zinc-500">{{ transaction.categoryLabel }}</span>
<span class="text-zinc-700"></span> <span class="text-zinc-700"></span>
<span class="text-[12px] text-zinc-600">{{ transaction.time }}</span> <span class="text-[12px] text-zinc-600">{{ transaction.time }}</span>
</div> </div>
@@ -175,11 +284,12 @@ const transactionGroups: TransactionGroup[] = [
<div class="text-right"> <div class="text-right">
<p :class="['text-[15px] font-bold', transaction.type === 'income' ? 'text-emerald-400' : 'text-white']"> <p :class="['text-[15px] font-bold', transaction.type === 'income' ? 'text-emerald-400' : 'text-white']">
{{ transaction.type === 'income' ? '+' : '-' }}${{ Math.abs(transaction.amount).toFixed(2) }} {{ transaction.type === 'income' ? '+' : '-' }}{{ formatAmount(transaction.amount) }}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>
+8
View File
@@ -75,6 +75,10 @@ const messages: Record<Locale, Messages> = {
'finance.transactions.today': 'Today', 'finance.transactions.today': 'Today',
'finance.transactions.yesterday': 'Yesterday', 'finance.transactions.yesterday': 'Yesterday',
'finance.transactions.apr1': 'Apr 1', 'finance.transactions.apr1': 'Apr 1',
'finance.transactions.loading': 'Loading transactions...',
'finance.transactions.empty': 'No transactions yet',
'finance.transactions.error': 'Failed to load transactions',
'finance.transactions.untitled': 'Transaction',
'finance.category.groceries': 'Groceries', 'finance.category.groceries': 'Groceries',
'finance.category.foodDining': 'Food & Dining', 'finance.category.foodDining': 'Food & Dining',
'finance.category.coffee': 'Coffee', 'finance.category.coffee': 'Coffee',
@@ -217,6 +221,10 @@ const messages: Record<Locale, Messages> = {
'finance.transactions.today': 'Сегодня', 'finance.transactions.today': 'Сегодня',
'finance.transactions.yesterday': 'Вчера', 'finance.transactions.yesterday': 'Вчера',
'finance.transactions.apr1': '1 апр', 'finance.transactions.apr1': '1 апр',
'finance.transactions.loading': 'Загрузка транзакций...',
'finance.transactions.empty': 'Транзакций пока нет',
'finance.transactions.error': 'Не удалось загрузить транзакции',
'finance.transactions.untitled': 'Транзакция',
'finance.category.groceries': 'Продукты', 'finance.category.groceries': 'Продукты',
'finance.category.foodDining': 'Еда и рестораны', 'finance.category.foodDining': 'Еда и рестораны',
'finance.category.coffee': 'Кофе', 'finance.category.coffee': 'Кофе',