|
|
|
@@ -1,21 +1,24 @@
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { type Component } from 'vue';
|
|
|
|
|
import { computed, ref, watch, type Component } from 'vue';
|
|
|
|
|
import {
|
|
|
|
|
Car,
|
|
|
|
|
Coffee,
|
|
|
|
|
CircleDollarSign,
|
|
|
|
|
Film,
|
|
|
|
|
Heart,
|
|
|
|
|
Home,
|
|
|
|
|
Receipt,
|
|
|
|
|
ShoppingBag,
|
|
|
|
|
TrendingUp,
|
|
|
|
|
Utensils,
|
|
|
|
|
} from 'lucide-vue-next';
|
|
|
|
|
import { useI18n } from '../i18n';
|
|
|
|
|
import { getTransactions, type Transaction as ApiTransaction } from '../api/transactions';
|
|
|
|
|
|
|
|
|
|
interface Transaction {
|
|
|
|
|
id: string;
|
|
|
|
|
interface TransactionViewModel {
|
|
|
|
|
id: number;
|
|
|
|
|
title: string;
|
|
|
|
|
categoryKey: string;
|
|
|
|
|
categoryLabel: string;
|
|
|
|
|
amount: number;
|
|
|
|
|
type: 'income' | 'expense';
|
|
|
|
|
icon: Component;
|
|
|
|
@@ -24,12 +27,17 @@ interface Transaction {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TransactionGroup {
|
|
|
|
|
dateKey: string;
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
total: number;
|
|
|
|
|
transactions: Transaction[];
|
|
|
|
|
transactions: TransactionViewModel[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
familyId?: number;
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
const { locale, t } = useI18n();
|
|
|
|
|
|
|
|
|
|
const colorMap = {
|
|
|
|
|
emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' },
|
|
|
|
@@ -42,144 +50,246 @@ const colorMap = {
|
|
|
|
|
indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400' },
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
const transactionGroups: TransactionGroup[] = [
|
|
|
|
|
{
|
|
|
|
|
dateKey: 'finance.transactions.today',
|
|
|
|
|
total: -245.5,
|
|
|
|
|
transactions: [
|
|
|
|
|
{
|
|
|
|
|
id: '1',
|
|
|
|
|
title: 'Whole Foods Market',
|
|
|
|
|
categoryKey: 'finance.category.groceries',
|
|
|
|
|
amount: -124.5,
|
|
|
|
|
type: 'expense',
|
|
|
|
|
icon: ShoppingBag,
|
|
|
|
|
color: 'emerald',
|
|
|
|
|
time: '2:30 PM',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
const transactions = ref<ApiTransaction[]>([]);
|
|
|
|
|
const isLoading = ref(false);
|
|
|
|
|
const errorMessage = ref('');
|
|
|
|
|
|
|
|
|
|
const categoryPresentationMap: Record<string, { icon: Component; color: keyof typeof colorMap; labelKey?: string }> = {
|
|
|
|
|
groceries: { icon: ShoppingBag, color: 'emerald', labelKey: 'finance.category.groceries' },
|
|
|
|
|
shopping: { icon: ShoppingBag, color: 'emerald' },
|
|
|
|
|
food: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
|
|
|
|
|
food_dining: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
|
|
|
|
|
dining: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
|
|
|
|
|
coffee: { icon: Coffee, color: 'amber', labelKey: 'finance.category.coffee' },
|
|
|
|
|
income: { icon: TrendingUp, color: 'blue', labelKey: 'finance.category.income' },
|
|
|
|
|
transport: { icon: Car, color: 'red', labelKey: 'finance.category.transport' },
|
|
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
);
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<div class="space-y-6">
|
|
|
|
|
<div v-for="group in transactionGroups" :key="group.dateKey">
|
|
|
|
|
<div class="mb-3 flex items-center justify-between px-1">
|
|
|
|
|
<h3 class="text-[14px] font-semibold text-white">{{ t(group.dateKey) }}</h3>
|
|
|
|
|
<span :class="['text-[13px] font-semibold', group.total >= 0 ? 'text-emerald-400' : 'text-zinc-400']">
|
|
|
|
|
{{ group.total >= 0 ? '+' : '' }}${{ Math.abs(group.total).toFixed(2) }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<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 class="space-y-2">
|
|
|
|
|
<div
|
|
|
|
|
v-for="transaction in group.transactions"
|
|
|
|
|
:key="transaction.id"
|
|
|
|
|
class="group flex cursor-pointer items-center gap-3 rounded-[16px] border border-white/[0.06] bg-[#16161F] p-3.5 transition-all hover:border-white/[0.1] hover:bg-[#1A1A24]"
|
|
|
|
|
>
|
|
|
|
|
<div :class="['flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[13px] transition-transform group-hover:scale-110', colorMap[transaction.color].bg]">
|
|
|
|
|
<component :is="transaction.icon" :class="['h-[19px] w-[19px]', colorMap[transaction.color].text]" :stroke-width="2" />
|
|
|
|
|
</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 class="min-w-0 flex-1">
|
|
|
|
|
<p class="mb-0.5 truncate text-[14px] font-semibold text-white">{{ transaction.title }}</p>
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<span class="text-[12px] text-zinc-500">{{ t(transaction.categoryKey) }}</span>
|
|
|
|
|
<span class="text-zinc-700">•</span>
|
|
|
|
|
<span class="text-[12px] text-zinc-600">{{ transaction.time }}</span>
|
|
|
|
|
<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">
|
|
|
|
|
<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']">
|
|
|
|
|
{{ group.total >= 0 ? '+' : '-' }}{{ formatAmount(group.total) }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<div
|
|
|
|
|
v-for="transaction in group.transactions"
|
|
|
|
|
:key="transaction.id"
|
|
|
|
|
class="group flex cursor-pointer items-center gap-3 rounded-[16px] border border-white/[0.06] bg-[#16161F] p-3.5 transition-all hover:border-white/[0.1] hover:bg-[#1A1A24]"
|
|
|
|
|
>
|
|
|
|
|
<div :class="['flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[13px] transition-transform group-hover:scale-110', colorMap[transaction.color].bg]">
|
|
|
|
|
<component :is="transaction.icon" :class="['h-[19px] w-[19px]', colorMap[transaction.color].text]" :stroke-width="2" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="text-right">
|
|
|
|
|
<p :class="['text-[15px] font-bold', transaction.type === 'income' ? 'text-emerald-400' : 'text-white']">
|
|
|
|
|
{{ transaction.type === 'income' ? '+' : '-' }}${{ Math.abs(transaction.amount).toFixed(2) }}
|
|
|
|
|
</p>
|
|
|
|
|
<div class="min-w-0 flex-1">
|
|
|
|
|
<p class="mb-0.5 truncate text-[14px] font-semibold text-white">{{ transaction.title }}</p>
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<span class="text-[12px] text-zinc-500">{{ transaction.categoryLabel }}</span>
|
|
|
|
|
<span class="text-zinc-700">•</span>
|
|
|
|
|
<span class="text-[12px] text-zinc-600">{{ transaction.time }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="text-right">
|
|
|
|
|
<p :class="['text-[15px] font-bold', transaction.type === 'income' ? 'text-emerald-400' : 'text-white']">
|
|
|
|
|
{{ transaction.type === 'income' ? '+' : '-' }}{{ formatAmount(transaction.amount) }}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|