Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39425af43e | |||
| e6096c98fa | |||
| b6447cce63 | |||
| b17b43b17a | |||
| baef5a0af2 |
@@ -0,0 +1,68 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ secrets.REGISTRY_URL }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push postgres image
|
||||
uses: docker/build-push-action@v5
|
||||
if: |
|
||||
contains(github.event.commits[0].modified, 'infra/docker/postgres-pg-cron') ||
|
||||
contains(github.event.commits[0].added, 'infra/docker/postgres-pg-cron')
|
||||
with:
|
||||
context: .
|
||||
file: infra/docker/postgres-pg-cron/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub-postgres:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Build and push app image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: infra/docker/application/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub:latest
|
||||
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Install kubectl
|
||||
run: |
|
||||
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
chmod +x kubectl
|
||||
sudo mv kubectl /usr/local/bin/
|
||||
|
||||
- name: Deploy to k3s
|
||||
env:
|
||||
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "$KUBECONFIG_DATA" > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
kubectl rollout restart deployment/application -n family-hub
|
||||
kubectl rollout restart deployment/postgres -n family-hub
|
||||
kubectl rollout status deployment/application -n family-hub --timeout=120s
|
||||
kubectl rollout status deployment/postgres -n family-hub --timeout=120s
|
||||
+3
-1
@@ -6,4 +6,6 @@ data
|
||||
archive
|
||||
volumes
|
||||
*.dtmp
|
||||
*.gocache
|
||||
*.gocache
|
||||
infra/k8s/secrets.yaml
|
||||
infra/k8s/google-creds.yaml
|
||||
@@ -0,0 +1 @@
|
||||
DROP EXTENSION pg_cron;
|
||||
@@ -128,6 +128,8 @@ func NewServer(cfg config.Config) *Server {
|
||||
authRouter := routers.NewAuthRouter(authService)
|
||||
authRouter.RegisterRouter(apiV1)
|
||||
|
||||
// подключаем статику Vue — должно быть последним
|
||||
registerStaticFiles(router)
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
Addr: cfg.APIHost + ":" + cfg.APIPort,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
var staticFiles embed.FS
|
||||
|
||||
func registerStaticFiles(router *gin.Engine) {
|
||||
// вырезаем префикс dist/ чтобы / отдавал index.html
|
||||
distFS, err := fs.Sub(staticFiles, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(distFS))
|
||||
|
||||
// все маршруты которые не /api и не /openapi — отдаём Vue
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -33,7 +34,6 @@ func Load() (Config, error) {
|
||||
mode := os.Getenv("RUN_MODE")
|
||||
debugMode := os.Getenv("DEBUG_MODE") == "true"
|
||||
botToken := os.Getenv("BOT_TOKEN")
|
||||
dbConnectionString := os.Getenv("DB_PATH")
|
||||
ocrTokenPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
apiPort := os.Getenv("API_PORT")
|
||||
apiHost := os.Getenv("API_HOST")
|
||||
@@ -42,6 +42,7 @@ func Load() (Config, error) {
|
||||
openAPIEndpoint := os.Getenv("OPEN_API_ENDPOINT")
|
||||
|
||||
runMode, err := ParseRunMode(mode)
|
||||
dbConnectionString := buildConnectionString()
|
||||
if err != nil {
|
||||
warnings = append(warnings, err.Error())
|
||||
}
|
||||
@@ -61,9 +62,6 @@ func Load() (Config, error) {
|
||||
if apiSecret == "" {
|
||||
warnings = append(warnings, "Missing required environment variable: API_SECRET")
|
||||
}
|
||||
if dbConnectionString == "" {
|
||||
dbConnectionString = "sqlite://data/app.db"
|
||||
}
|
||||
if apiHost == "" {
|
||||
apiHost = "localhost"
|
||||
}
|
||||
@@ -92,3 +90,30 @@ func Load() (Config, error) {
|
||||
TelegramApi: "https://api.telegram.org",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildConnectionString() string {
|
||||
// если задана готовая строка — используем её (удобно для локальной разработки через .env)
|
||||
if dsn := os.Getenv("DB_PATH"); dsn != "" {
|
||||
return dsn
|
||||
}
|
||||
|
||||
// собираем из отдельных переменных (для Kubernetes)
|
||||
host := os.Getenv("DB_HOST")
|
||||
port := os.Getenv("DB_PORT")
|
||||
user := os.Getenv("DB_USER")
|
||||
password := os.Getenv("DB_PASSWORD")
|
||||
dbName := os.Getenv("DB_NAME")
|
||||
|
||||
if host == "" || user == "" || password == "" || dbName == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if port == "" {
|
||||
port = "5432"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||
user, password, host, port, dbName,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
25.8.2
|
||||
@@ -23,5 +23,10 @@
|
||||
"overrides": {
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": "^25.5.2",
|
||||
"npm": ">=10"
|
||||
},
|
||||
"packageManager": "npm@11.11.1"
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<FinanceScreen
|
||||
v-if="activeScreen === 'finance'"
|
||||
:family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 : []
|
||||
}
|
||||
@@ -13,6 +13,11 @@ type Tab = 'transactions' | 'analytics' | 'categories';
|
||||
const emit = defineEmits<{
|
||||
navigate: [screen: string];
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
familyId?: number;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const activeTab = ref<Tab>('transactions');
|
||||
@@ -76,7 +81,7 @@ const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransactionsList v-if="activeTab === 'transactions'" />
|
||||
<TransactionsList v-if="activeTab === 'transactions'" :family-id="familyId" />
|
||||
<AnalyticsView v-else-if="activeTab === 'analytics'" />
|
||||
<CategoriesView v-else />
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -75,6 +75,10 @@ const messages: Record<Locale, Messages> = {
|
||||
'finance.transactions.today': 'Today',
|
||||
'finance.transactions.yesterday': 'Yesterday',
|
||||
'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.foodDining': 'Food & Dining',
|
||||
'finance.category.coffee': 'Coffee',
|
||||
@@ -217,6 +221,10 @@ const messages: Record<Locale, Messages> = {
|
||||
'finance.transactions.today': 'Сегодня',
|
||||
'finance.transactions.yesterday': 'Вчера',
|
||||
'finance.transactions.apr1': '1 апр',
|
||||
'finance.transactions.loading': 'Загрузка транзакций...',
|
||||
'finance.transactions.empty': 'Транзакций пока нет',
|
||||
'finance.transactions.error': 'Не удалось загрузить транзакции',
|
||||
'finance.transactions.untitled': 'Транзакция',
|
||||
'finance.category.groceries': 'Продукты',
|
||||
'finance.category.foodDining': 'Еда и рестораны',
|
||||
'finance.category.coffee': 'Кофе',
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# ================================
|
||||
# Stage 1: сборка Vue
|
||||
# ================================
|
||||
FROM node:20-alpine AS frontend
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ================================
|
||||
# Stage 2: сборка Go
|
||||
# ================================
|
||||
FROM golang:1.26-bookworm AS backend
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# зависимости отдельно — используем кэш слоёв
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# исходники
|
||||
COPY backend/ ./
|
||||
|
||||
# встраиваем собранную статику Vue
|
||||
COPY --from=frontend /app/dist ./src/api/dist
|
||||
# Миграции кладём туда, откуда Go их ищет
|
||||
COPY backend/migrations ./migrations
|
||||
# сборка бинарника
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./src/
|
||||
|
||||
# ================================
|
||||
# Stage 3: финальный образ
|
||||
# ================================
|
||||
FROM scratch
|
||||
|
||||
COPY --from=backend /app/server /server
|
||||
COPY --from=backend /app/migrations /migrations
|
||||
COPY --from=backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=backend /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
|
||||
ENTRYPOINT ["/server"]
|
||||
@@ -1,24 +0,0 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
db:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: infra/docker/postgres-pg-cron/Dockerfile
|
||||
container_name: postgres
|
||||
restart: always
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- shared_preload_libraries=pg_cron
|
||||
- -c
|
||||
- cron.database_name=familyHubDB
|
||||
environment:
|
||||
POSTGRES_USER: familyUser
|
||||
POSTGRES_PASSWORD: familyPass
|
||||
POSTGRES_DB: familyHubDB
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- ./volumes/postgres:/var/lib/postgresql/data
|
||||
- ./docker/postgres-pg-cron/init:/docker-entrypoint-initdb.d
|
||||
@@ -0,0 +1,55 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: git.myhomecloud.tech/admin/familyhub:latest
|
||||
container_name: application
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DB_HOST=${DB_HOST}
|
||||
- DB_PORT=${DB_PORT}
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME}
|
||||
- BOT_TOKEN=${BOT_TOKEN}
|
||||
- GOOGLE_APPLICATION_CREDENTIALS=${GOOGLE_APPLICATION_CREDENTIALS}
|
||||
- RUN_MODE=${RUN_MODE}
|
||||
- API_SECRET=${API_SECRET}
|
||||
- DB_PATH=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable
|
||||
- OPEN_API_ENABLED=${OPEN_API_ENABLED}
|
||||
- DEBUG_MODE=${DEBUG_MODE}
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- family-hub-net
|
||||
|
||||
db:
|
||||
image: git.myhomecloud.tech/admin/familyhub-postgres:latest
|
||||
container_name: postgres
|
||||
restart: always
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "5432:5432"
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- shared_preload_libraries=pg_cron
|
||||
- -c
|
||||
- cron.database_name=familyHubDB
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./init:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- family-hub-net
|
||||
|
||||
networks:
|
||||
family-hub-net:
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
@@ -1,5 +0,0 @@
|
||||
FROM postgres:16
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-16-cron \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -0,0 +1,62 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: application
|
||||
namespace: family-hub
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: application
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: application
|
||||
spec:
|
||||
containers:
|
||||
- name: application
|
||||
image: git.myhomecloud.tech/admin/familyhub:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: family-hub-config
|
||||
- secretRef:
|
||||
name: family-hub-secrets
|
||||
env:
|
||||
- name: GOOGLE_APPLICATION_CREDENTIALS
|
||||
value: /secrets/credentials.json
|
||||
volumeMounts:
|
||||
- name: google-credentials
|
||||
mountPath: /secrets
|
||||
readOnly: true
|
||||
# livenessProbe:
|
||||
# httpGet:
|
||||
# path: /api/v1/health
|
||||
# port: 8000
|
||||
# initialDelaySeconds: 10
|
||||
# periodSeconds: 30
|
||||
# readinessProbe:
|
||||
# httpGet:
|
||||
# path: /api/v1/health
|
||||
# port: 8000
|
||||
# initialDelaySeconds: 5
|
||||
# periodSeconds: 10
|
||||
volumes:
|
||||
- name: google-credentials
|
||||
secret:
|
||||
secretName: google-credentials
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: application
|
||||
namespace: family-hub
|
||||
spec:
|
||||
selector:
|
||||
app: application
|
||||
ports:
|
||||
- port: 9876
|
||||
targetPort: 8000
|
||||
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: family-hub-config
|
||||
namespace: family-hub
|
||||
data:
|
||||
DB_HOST: postgres
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: familyHubDB
|
||||
DB_USER: familyUser
|
||||
API_PORT: "8000"
|
||||
API_HOST: 0.0.0.0
|
||||
RUN_MODE: standalone
|
||||
OPEN_API_ENABLED: "true"
|
||||
DEBUG_MODE: "false"
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: application
|
||||
namespace: family-hub
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||
spec:
|
||||
rules:
|
||||
- host: application.local
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: application
|
||||
port:
|
||||
number: 9876
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: family-hub
|
||||
@@ -0,0 +1,62 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: family-hub
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: git.myhomecloud.tech/admin/familyhub-postgres:latest
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: familyUser
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: family-hub-secrets
|
||||
key: DB_PASSWORD
|
||||
- name: POSTGRES_DB
|
||||
value: familyHubDB
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
volumeMounts:
|
||||
- name: postgres-data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
volumes:
|
||||
- name: postgres-data
|
||||
persistentVolumeClaim:
|
||||
claimName: postgres-pvc
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: family-hub
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: postgres-pvc
|
||||
namespace: family-hub
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM postgres:16
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql-16-cron \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
|
||||
RUN echo "shared_preload_libraries = 'pg_cron'" >> /usr/share/postgresql/postgresql.conf.sample \
|
||||
&& echo "cron.database_name = 'familyHubDB'" >> /usr/share/postgresql/postgresql.conf.sample
|
||||
Reference in New Issue
Block a user