5 Commits

30 changed files with 2259 additions and 151 deletions
+12 -6
View File
@@ -34,24 +34,30 @@ jobs:
push: true
tags: |
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub-postgres:latest
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub-postgres:cache
cache-to: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub-postgres:cache,mode=max
- name: Build and push app image
uses: docker/build-push-action@v5
with:
context: .
file: infra/docker/application/Dockerfile
file: infra/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
cache-from: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub:cache
cache-to: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub:cache,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"
ARCH=$(uname -m)
case $ARCH in
x86_64) KUBECTL_ARCH="amd64" ;;
aarch64) KUBECTL_ARCH="arm64" ;;
armv7l) KUBECTL_ARCH="arm" ;;
esac
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/${KUBECTL_ARCH}/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/
+41 -21
View File
@@ -5,9 +5,11 @@ import (
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
receiptServiceIntegration "FamilyHub/src/integrations/receiptProvider"
"context"
"database/sql"
"errors"
"log"
"net"
"net/http"
"runtime/debug"
@@ -38,27 +40,7 @@ func logInternalError(c *gin.Context, scope string, err error) {
}
func handleReceiptError(c *gin.Context, err error) {
var externalErr *receiptServiceIntegration.ExternalServiceError
switch {
case errors.Is(err, receiptServiceIntegration.ErrReceiptNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.As(err, &externalErr):
log.Printf(
"receipt external service error: method=%s path=%s upstream_status=%d upstream_body=%q",
c.Request.Method,
c.Request.URL.Path,
externalErr.StatusCode,
externalErr.Body,
)
logError(c, "receipt external service", err)
switch externalErr.StatusCode {
case http.StatusForbidden, http.StatusTooManyRequests:
c.JSON(http.StatusServiceUnavailable, dto.ErrorResponse{Message: "receipt service temporarily unavailable"})
default:
c.JSON(http.StatusBadGateway, dto.ErrorResponse{Message: "receipt service error"})
}
default:
if !handleReceiptProviderError(c, err) {
logInternalError(c, "receipt request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
@@ -96,3 +78,41 @@ func handleUserError(c *gin.Context, err error) {
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
}
func handleReceiptProviderError(c *gin.Context, err error) bool {
var externalErr *receiptServiceIntegration.ExternalServiceError
switch {
case errors.Is(err, services.ErrReceiptNotFound),
errors.Is(err, receiptServiceIntegration.ErrReceiptNotFound):
logError(c, "receipt request", err)
c.JSON(http.StatusUnprocessableEntity, dto.ErrorResponse{Message: err.Error()})
return true
case isTimeoutError(err):
logError(c, "receipt request", err)
c.JSON(http.StatusGatewayTimeout, dto.ErrorResponse{Message: "receipt service timeout"})
return true
case errors.As(err, &externalErr):
log.Printf(
"receipt external service error: method=%s path=%s upstream_status=%d upstream_body=%q",
c.Request.Method,
c.Request.URL.Path,
externalErr.StatusCode,
externalErr.Body,
)
logError(c, "receipt external service", err)
c.JSON(http.StatusServiceUnavailable, dto.ErrorResponse{Message: "receipt service unavailable"})
return true
default:
return false
}
}
func isTimeoutError(err error) bool {
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
}
+10 -2
View File
@@ -65,12 +65,14 @@ func (router *TransactionsRouter) Create(c *gin.Context) {
var req dto.CreateTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
input, err := requests.BuildCreateTransactionInput(req)
if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
@@ -87,6 +89,7 @@ func (router *TransactionsRouter) Create(c *gin.Context) {
func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
fileHeader, err := c.FormFile("photo")
if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"})
return
}
@@ -108,11 +111,13 @@ func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
familyID, err := parseOptionalInt64Form(c, "family_id")
if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
createdBy, err := parseOptionalInt64Form(c, "created_by")
if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
@@ -126,6 +131,7 @@ func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
Description: parseOptionalStringForm(c, "description"),
})
if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
@@ -335,6 +341,10 @@ func (router *TransactionsRouter) Delete(c *gin.Context) {
}
func handleTransactionError(c *gin.Context, err error) {
if handleReceiptProviderError(c, err) {
return
}
switch {
case errors.Is(err, services.ErrTransactionNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
@@ -352,8 +362,6 @@ func handleTransactionError(c *gin.Context, err error) {
errors.Is(err, services.ErrOCRNotConfigured),
errors.Is(err, services.ErrReceiptTransactionNotCreated):
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
default:
logInternalError(c, "transaction request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
@@ -5,6 +5,7 @@ import (
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
receiptProvider "FamilyHub/src/integrations/receiptProvider"
"bytes"
"context"
"errors"
@@ -199,6 +200,54 @@ func TestTransactionsRouter_Create(t *testing.T) {
assert.Contains(t, w.Body.String(), `"id":21`)
})
t.Run("creates transaction from receipt with iso date", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, validNumber, req.Number)
assert.Equal(t, "2025-11-09", req.Date)
require.NotNil(t, req.FamilyID)
require.NotNil(t, req.CreatedBy)
assert.Equal(t, int64(1), *req.FamilyID)
assert.Equal(t, int64(1), *req.CreatedBy)
return &domain.Receipt{ID: 9, ReceiptNumber: validNumber, IssuedAt: now, TransactionID: ptrInt64(23)}, nil
}}
service := &transactionServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) {
assert.Equal(t, int64(23), id)
return &domain.Transaction{
ID: 23,
FamilyID: 1,
Type: "expense",
DateTime: now,
Category: "receipt",
Amount: 89.4,
CreatedBy: 1,
CreatedAt: now,
ReceiptID: ptrInt64(9),
}, nil
}}
creationService := services.NewTransactionCreationService(service, receiptSvc, nil)
router := NewTransactionsRouter(service, creationService)
router.RegisterRoutes(apiV1)
req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{
"family_id":1,
"created_by":1,
"receipt_number":"`+validNumber+`",
"receipt_date":"2025-11-09",
"type":"expense"
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), `"id":23`)
})
t.Run("creates transaction from photo upload", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
@@ -290,6 +339,84 @@ func TestTransactionsRouter_Create(t *testing.T) {
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "family_id and created_by are required for receipt transaction")
})
t.Run("returns 422 when receipt is not found in photo flow", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
return "21.01.2026 " + validNumber, nil
}}
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
return nil, receiptProvider.ErrReceiptNotFound
}}
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, map[string]string{
"family_id": "1",
"created_by": "2",
})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusUnprocessableEntity, w.Code)
assert.Contains(t, w.Body.String(), "receipt not found")
})
t.Run("returns 503 when receipt provider is unavailable in photo flow", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
return "21.01.2026 " + validNumber, nil
}}
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
return nil, &receiptProvider.ExternalServiceError{StatusCode: http.StatusBadGateway, Body: "upstream failed"}
}}
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, map[string]string{
"family_id": "1",
"created_by": "2",
})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusServiceUnavailable, w.Code)
assert.Contains(t, w.Body.String(), "receipt service unavailable")
})
t.Run("returns 504 when receipt provider times out in photo flow", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
return "21.01.2026 " + validNumber, nil
}}
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
return nil, context.DeadlineExceeded
}}
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, map[string]string{
"family_id": "1",
"created_by": "2",
})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusGatewayTimeout, w.Code)
assert.Contains(t, w.Body.String(), "receipt service timeout")
})
}
func ptrInt64(v int64) *int64 {
+1 -1
View File
@@ -129,7 +129,7 @@ func NewServer(cfg config.Config) *Server {
authRouter.RegisterRouter(apiV1)
// подключаем статику Vue — должно быть последним
registerStaticFiles(router)
registerStaticFiles(router, "src/api/dist")
return &Server{
httpServer: &http.Server{
Addr: cfg.APIHost + ":" + cfg.APIPort,
+1 -1
View File
@@ -119,7 +119,7 @@ func buildReceiptTransactionDescription(receipt *domain.Receipt, explicit *strin
return &value
}
if name := strings.TrimSpace(receipt.NameSPD); name != "" {
if name := strings.TrimSpace(receipt.NameTO); name != "" {
return &name
}
+23
View File
@@ -0,0 +1,23 @@
package services
import (
"FamilyHub/src/domain"
"testing"
)
func TestBuildReceiptTransactionDescription_UsesNameTO(t *testing.T) {
receipt := &domain.Receipt{
NameSPD: "Old merchant name",
NameTO: "Expected terminal name",
ReceiptNumber: "77F05E82C1ED044B07194794",
}
description := buildReceiptTransactionDescription(receipt, nil)
if description == nil {
t.Fatal("expected description to be set")
}
if *description != "Expected terminal name" {
t.Fatalf("expected description %q, got %q", "Expected terminal name", *description)
}
}
+10 -10
View File
@@ -1,26 +1,26 @@
package api
import (
"embed"
"io/fs"
"net/http"
"os"
"github.com/gin-gonic/gin"
)
//go:embed dist
var staticFiles embed.FS
func registerStaticFiles(router *gin.Engine, staticDir string) {
if _, err := os.Stat(staticDir); err != nil {
if os.IsNotExist(err) {
router.NoRoute(func(c *gin.Context) {
c.Status(http.StatusNotFound)
})
return
}
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))
fileServer := http.FileServer(http.Dir(staticDir))
// все маршруты которые не /api и не /openapi — отдаём Vue
router.NoRoute(func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
+45
View File
@@ -0,0 +1,45 @@
package api
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestRegisterStaticFilesReturns404WhenDirectoryIsMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
registerStaticFiles(router, filepath.Join(t.TempDir(), "missing-dist"))
req := httptest.NewRequest(http.MethodGet, "/some-route", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusNotFound, recorder.Code)
}
func TestRegisterStaticFilesServesExistingDirectory(t *testing.T) {
gin.SetMode(gin.TestMode)
staticDir := t.TempDir()
indexPath := filepath.Join(staticDir, "index.html")
require.NoError(t, os.WriteFile(indexPath, []byte("ok"), 0o644))
router := gin.New()
registerStaticFiles(router, staticDir)
req := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, "ok", recorder.Body.String())
}
@@ -172,7 +172,12 @@ func buildMultipartBody(date, number string) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("orig_date", date)
normalizedDate := strings.TrimSpace(date)
if isoDate, err := utils.NormalizeDateToISO(normalizedDate); err == nil {
normalizedDate = isoDate
}
_ = writer.WriteField("orig_date", normalizedDate)
_ = writer.WriteField("orig_ui", number)
_ = writer.Close()
@@ -0,0 +1,19 @@
package receiptProvider
import (
"strings"
"testing"
)
func TestBuildMultipartBody_NormalizesDateToISO(t *testing.T) {
body, _ := buildMultipartBody("07.04.2026", "77F05E82C1ED044B07194794")
payload := body.String()
if !strings.Contains(payload, "2026-04-07") {
t.Fatalf("expected ISO date in multipart body, got %q", payload)
}
if strings.Contains(payload, "07.04.2026") {
t.Fatalf("did not expect source date format in multipart body, got %q", payload)
}
}
+3 -3
View File
@@ -13,9 +13,9 @@ var knownDateFormats = []string{
"02.01.06", // 21.01.2026
"02-01-2006", // 21-01-2026
"02/01/2006", // 21/01/2026
//"2006/01/02", // 2026/01/21
//"2006-01-02", // 2026-01-21
//"2006.01.02", // 2026.01.21
"2006/01/02", // 2026/01/21
"2006-01-02", // 2026-01-21
"2006.01.02", // 2026.01.21
}
func NormalizeDateToISO(input string) (string, error) {
+21 -6
View File
@@ -1,6 +1,10 @@
package utils
import "regexp"
import (
"regexp"
"strings"
"time"
)
type ReceiptMeta struct {
Date string
@@ -10,17 +14,16 @@ type ReceiptMeta struct {
func ExtractReceiptMeta(text string) ReceiptMeta {
result := ReceiptMeta{}
// --- ДАТА ---
datePatterns := []string{
`(\d{2}[./-]\d{2}[./-]\d{4})`, // 25.01.2026
`(\d{2}[./-]\d{2}[./-]\d{2})`, // 25.01.26
`(\d{4}[./-]\d{2}[./-]\d{2})`, // 2026-01-25
`\b\d{2}[./:-]\d{2}[./:-]\d{4}\b`, // 25.01.2026, 25:01.2026
`\b\d{4}[./:-]\d{2}[./:-]\d{2}\b`, // 2026-01-25
`\b\d{2}[./-]\d{2}[./-]\d{2}\b`, // 25.01.26
}
for _, pattern := range datePatterns {
re := regexp.MustCompile(pattern)
if match := re.FindString(text); match != "" {
result.Date = match
result.Date = normalizeOCRDate(match)
break
}
}
@@ -31,3 +34,15 @@ func ExtractReceiptMeta(text string) ReceiptMeta {
return result
}
func normalizeOCRDate(value string) string {
sanitized := strings.NewReplacer(":", ".", "/", ".", "-", ".").Replace(strings.TrimSpace(value))
for _, layout := range knownDateFormats {
if t, err := time.Parse(layout, sanitized); err == nil {
return t.Format("02.01.2006")
}
}
return strings.TrimSpace(value)
}
+25
View File
@@ -0,0 +1,25 @@
package utils
import "testing"
func TestExtractReceiptMeta_NormalizesOCRDateWithColon(t *testing.T) {
text := "Двойной Американо\n1762992079489\n1,000 x 5,50\n5,50\nИТОГО К ОПЛАТЕ:\n5,50\nБП Карта:\n5,50\nКассир:\nср Кто все эти л\nДата и время:\n07:04.2026 08:57:14\nУИ:\n77F05E82C1ED044B07194794"
meta := ExtractReceiptMeta(text)
if meta.Date != "07.04.2026" {
t.Fatalf("expected normalized date %q, got %q", "07.04.2026", meta.Date)
}
if meta.ReceiptID != "77F05E82C1ED044B07194794" {
t.Fatalf("expected receipt id %q, got %q", "77F05E82C1ED044B07194794", meta.ReceiptID)
}
}
func TestExtractReceiptMeta_DoesNotTreatTimeAsDate(t *testing.T) {
meta := ExtractReceiptMeta("Дата и время:\n08:57:14\nУИ:\n77F05E82C1ED044B07194794")
if meta.Date != "" {
t.Fatalf("expected empty date, got %q", meta.Date)
}
}
+58 -21
View File
@@ -1,23 +1,22 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import Header from './components/Header.vue';
import Navigation from './components/Navigation.vue';
import BalanceWidget from './components/BalanceWidget.vue';
import TodayWidget from './components/TodayWidget.vue';
import RecentActivityWidget from './components/RecentActivityWidget.vue';
import SwipeCards from './components/SwipeCards.vue';
import FinanceScreen from './components/FinanceScreen.vue';
import SettingsScreen from './components/SettingsScreen.vue';
import { getFamilyById } from './api/families';
import { useI18n } from './i18n';
import HomeScreen from "@/components/HomeScreen.vue";
import CalendarScreen from "@/components/CalendarScreen.vue";
import IntimacyScreen from "@/components/IntimacyScreen.vue";
import {Heart} from "lucide-vue-next";
const activeScreen = ref('home');
const previousScreen = ref('home');
const familyName = ref<string | null>(null);
const { t } = useI18n();
const familyOwnerId = ref<number | null>(null);
const configuredFamilyId = Number.parseInt(import.meta.env.VITE_FAMILY_ID ?? '1', 10);
const headerFamilyName = computed(() => familyName.value?.trim() || t('header.familyName'));
const configuredUserId = Number.parseInt(import.meta.env.VITE_USER_ID ?? '', 10);
function handleNavigate(screen: string) {
if (screen === 'settings') {
@@ -44,6 +43,7 @@ async function loadFamily() {
try {
const family = await getFamilyById(configuredFamilyId);
familyName.value = family.name;
familyOwnerId.value = family.owner_id;
} catch (error) {
console.error('Failed to load family', error);
}
@@ -52,34 +52,71 @@ async function loadFamily() {
onMounted(() => {
void loadFamily();
});
const resolvedUserId = computed(() => {
if (Number.isFinite(configuredUserId) && configuredUserId > 0) {
return configuredUserId;
}
return familyOwnerId.value;
});
</script>
<template>
<!-- Home Screen -->
<HomeScreen
v-if="activeScreen === 'home'"
:family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
:on-navigate="handleNavigate"
@navigate="handleNavigate"
/>
<!-- Finance Screen -->
<FinanceScreen
v-if="activeScreen === 'finance'"
v-else-if="activeScreen === 'finance'"
:family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
:user-id="resolvedUserId ?? undefined"
:on-navigate="handleNavigate"
@navigate="handleNavigate"
/>
<!-- Settings Screen -->
<SettingsScreen
v-else-if="activeScreen === 'settings'"
@navigate="handleNavigate"
/>
<div v-else class="min-h-screen bg-[#0A0A0F] dark">
<div class="mx-auto flex min-h-screen max-w-md flex-col relative">
<Header :family-name="headerFamilyName" @navigate="handleNavigate" />
<!-- Calendar Screen -->
<CalendarScreen
v-else-if="activeScreen === 'calendar'"
:family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
@navigate="handleNavigate"
/>
<main class="flex-1 overflow-y-auto px-5 pb-32">
<div class="space-y-4">
<BalanceWidget />
<TodayWidget />
<SwipeCards />
<RecentActivityWidget />
</div>
</main>
<!-- Intimacy Screen -->
<IntimacyScreen
v-else-if="activeScreen === 'intimacy'"
:family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
@navigate="handleNavigate"
/>
<Navigation :active-screen="activeScreen" @navigate="handleNavigate" />
<!-- Fallback for other screens -->
<div v-else class="min-h-screen bg-[#0A0A0F] dark flex items-center justify-center">
<div class="text-center px-8">
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-purple-500 to-blue-600 flex items-center justify-center mx-auto mb-4">
<Heart :size="32" :stroke-width="2" class="text-white" />
</div>
<h2 class="text-white text-[20px] font-bold mb-2">{{ activeScreen.charAt(0).toUpperCase() + activeScreen.slice(1) }}</h2>
<p class="text-zinc-400 text-[14px] mb-6">
This screen is coming soon!
</p>
<button
@click="handleNavigate('home')"
class="px-6 py-3 rounded-[14px] bg-gradient-to-br from-purple-500 to-blue-600 text-white text-[14px] font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all"
>
Go to Home
</button>
</div>
<Navigation :active-screen="activeScreen" @navigate="handleNavigate" />
</div>
</template>
+64
View File
@@ -41,3 +41,67 @@ export async function getTransactions(options: GetTransactionsOptions = {}): Pro
const payload = await response.json() as TransactionsResponse
return Array.isArray(payload.items) ? payload.items : []
}
export interface CreateTransactionData {
family_id: number
type?: string
category?: string
amount?: number
datetime?: string
description?: string
receipt_number?: string
receipt_date?: string
}
// TODO: Replace with the authenticated user id when frontend auth is implemented.
const TRANSACTION_CREATOR_ID = 1
export async function createTransaction(data: CreateTransactionData): Promise<Transaction> {
const response = await fetch('/api/v1/transactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...data,
created_by: TRANSACTION_CREATOR_ID,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }))
throw new Error(error.message || `Failed to create transaction: ${response.status}`)
}
return response.json() as Promise<Transaction>
}
export interface CreateTransactionPhotoData {
photo: File
family_id: number
type?: string
category?: string
description?: string
}
export async function createTransactionFromPhoto(data: CreateTransactionPhotoData): Promise<Transaction> {
const formData = new FormData()
formData.append('photo', data.photo)
formData.append('family_id', String(data.family_id))
formData.append('created_by', String(TRANSACTION_CREATOR_ID))
if (data.type) formData.append('type', data.type)
if (data.category) formData.append('category', data.category)
if (data.description) formData.append('description', data.description)
const response = await fetch('/api/v1/transactions', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }))
throw new Error(error.message || `Failed to create transaction from photo: ${response.status}`)
}
return response.json() as Promise<Transaction>
}
@@ -0,0 +1,672 @@
<template>
<div class="min-h-screen bg-[#0A0A0F] dark">
<div class="mx-auto max-w-md min-h-screen flex flex-col relative">
<header
v-motion
:initial="{ opacity: 0, y: -20 }"
:enter="{ opacity: 1, y: 0 }"
class="flex items-center justify-between px-5 pt-6 pb-4"
>
<div class="flex items-center gap-3">
<div class="w-11 h-11 rounded-[16px] bg-gradient-to-br from-purple-500 to-blue-600 flex items-center justify-center shadow-lg shadow-purple-500/20">
<DollarSign :size="20" :stroke-width="2.5" class="text-white" />
</div>
<div>
<p class="text-[11px] text-zinc-500 font-normal mb-0.5">{{ t('finance.add.eyebrow') }}</p>
<h1 class="text-[17px] text-white font-semibold tracking-tight">{{ t('finance.add.title') }}</h1>
</div>
</div>
<button
type="button"
@click="emit('close')"
class="w-10 h-10 rounded-[14px] bg-[#1A1A24] flex items-center justify-center hover:bg-[#222230] transition-colors border border-white/5"
>
<X :size="18" :stroke-width="2" class="text-zinc-400" />
</button>
</header>
<div class="px-5 pb-4">
<div class="flex items-center gap-2 p-1.5 bg-[#16161F] rounded-[16px] border border-white/[0.06]">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
@click="activeTab = tab.id"
:disabled="isLoading"
class="relative flex-1 px-3 py-2.5 rounded-[12px] transition-all disabled:opacity-50"
>
<div
v-if="activeTab === tab.id"
v-motion="'activeTransactionTab'"
:initial="false"
class="absolute inset-0 bg-gradient-to-br from-purple-500/15 to-blue-500/15 rounded-[12px] border border-purple-500/20"
/>
<div class="relative z-10 flex items-center justify-center gap-2">
<component
:is="tab.icon"
:size="16"
:stroke-width="2"
:class="activeTab === tab.id ? 'text-purple-400' : 'text-zinc-500'"
/>
<span
:class="[
'text-[13px] font-semibold',
activeTab === tab.id ? 'text-purple-400' : 'text-zinc-500',
]"
>
{{ t(tab.label) }}
</span>
</div>
</button>
</div>
</div>
<div v-if="errorMessage" class="px-5 mb-4">
<div class="p-3.5 rounded-[14px] bg-rose-500/10 border border-rose-500/20 text-rose-400 text-[13px] font-medium">
{{ errorMessage }}
</div>
</div>
<main class="flex-1 overflow-y-auto px-5 pb-6">
<Transition mode="out-in" name="fade">
<div v-if="activeTab === 'manual'" key="manual" class="space-y-4">
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.type') }} <span class="text-rose-400">*</span>
</label>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
@click="handleTypeSelect('expense')"
:disabled="isLoading"
:class="[
'p-4 rounded-[14px] border transition-all',
transactionType === 'expense'
? 'bg-rose-500/10 border-rose-500/30'
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
]"
>
<ArrowDownCircle
:size="20"
:stroke-width="2"
:class="transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-500'"
class="mb-2 mx-auto"
/>
<p
:class="[
'text-[13px] font-semibold',
transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-400',
]"
>
{{ t('finance.add.type.expense') }}
</p>
</button>
<button
type="button"
@click="handleTypeSelect('income')"
:disabled="isLoading"
:class="[
'p-4 rounded-[14px] border transition-all',
transactionType === 'income'
? 'bg-emerald-500/10 border-emerald-500/30'
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
]"
>
<ArrowUpCircle
:size="20"
:stroke-width="2"
:class="transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-500'"
class="mb-2 mx-auto"
/>
<p
:class="[
'text-[13px] font-semibold',
transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-400',
]"
>
{{ t('finance.add.type.income') }}
</p>
</button>
</div>
</div>
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.amount') }} <span class="text-rose-400">*</span>
</label>
<div class="relative">
<DollarSign :size="20" :stroke-width="2" class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
v-model="amount"
type="number"
step="0.01"
min="0"
placeholder="0.00"
:disabled="isLoading"
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white placeholder:text-zinc-600 focus:border-purple-500/30 focus:outline-none transition-colors disabled:opacity-50"
/>
</div>
</div>
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.category') }} <span class="text-rose-400">*</span>
</label>
<select
v-model="selectedCategory"
:disabled="isLoading"
class="w-full px-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white focus:border-purple-500/30 focus:outline-none transition-colors appearance-none disabled:opacity-50"
>
<option value="" class="bg-[#16161F]">{{ t('finance.add.category.select') }}</option>
<option v-for="category in availableCategories" :key="category.id" :value="category.id" class="bg-[#16161F]">
{{ t(category.label) }}
</option>
</select>
</div>
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.datetime') }} <span class="text-rose-400">*</span>
</label>
<div class="relative">
<Calendar :size="20" :stroke-width="2" class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
v-model="datetime"
type="datetime-local"
:disabled="isLoading"
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white focus:border-purple-500/30 focus:outline-none transition-colors disabled:opacity-50"
/>
</div>
</div>
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.description') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
</label>
<div class="relative">
<FileText :size="20" :stroke-width="2" class="absolute left-4 top-4 text-zinc-500" />
<textarea
v-model="description"
:placeholder="t('finance.add.description.placeholder')"
rows="3"
:disabled="isLoading"
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white placeholder:text-zinc-600 focus:border-purple-500/30 focus:outline-none transition-colors resize-none disabled:opacity-50"
/>
</div>
</div>
</div>
<div v-else-if="activeTab === 'receipt'" key="receipt" class="space-y-4">
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.receiptNumber') }} <span class="text-rose-400">*</span>
</label>
<div class="relative">
<Receipt :size="20" :stroke-width="2" class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
v-model="receiptNumber"
type="text"
:placeholder="t('finance.add.receiptNumber.placeholder')"
:disabled="isLoading"
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white placeholder:text-zinc-600 focus:border-purple-500/30 focus:outline-none transition-colors disabled:opacity-50"
/>
</div>
</div>
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.receiptDate') }} <span class="text-rose-400">*</span>
</label>
<div class="relative">
<Calendar :size="20" :stroke-width="2" class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
v-model="receiptDate"
type="date"
:disabled="isLoading"
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white focus:border-purple-500/30 focus:outline-none transition-colors disabled:opacity-50"
/>
</div>
</div>
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.type') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
</label>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
@click="handleTypeSelect('expense')"
:disabled="isLoading"
:class="[
'p-3 rounded-[14px] border transition-all',
transactionType === 'expense'
? 'bg-rose-500/10 border-rose-500/30'
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
]"
>
<ArrowDownCircle
:size="16"
:stroke-width="2"
:class="transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-500'"
class="mb-1 mx-auto"
/>
<p
:class="[
'text-[12px] font-semibold',
transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-400',
]"
>
{{ t('finance.add.type.expense') }}
</p>
</button>
<button
type="button"
@click="handleTypeSelect('income')"
:disabled="isLoading"
:class="[
'p-3 rounded-[14px] border transition-all',
transactionType === 'income'
? 'bg-emerald-500/10 border-emerald-500/30'
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
]"
>
<ArrowUpCircle
:size="16"
:stroke-width="2"
:class="transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-500'"
class="mb-1 mx-auto"
/>
<p
:class="[
'text-[12px] font-semibold',
transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-400',
]"
>
{{ t('finance.add.type.income') }}
</p>
</button>
</div>
</div>
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.category') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
</label>
<select
v-model="selectedCategory"
:disabled="isLoading"
class="w-full px-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white focus:border-purple-500/30 focus:outline-none transition-colors appearance-none disabled:opacity-50"
>
<option value="" class="bg-[#16161F]">{{ t('finance.add.category.select') }}</option>
<option v-for="category in availableCategories" :key="category.id" :value="category.id" class="bg-[#16161F]">
{{ t(category.label) }}
</option>
</select>
</div>
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.description') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
</label>
<div class="relative">
<FileText :size="20" :stroke-width="2" class="absolute left-4 top-4 text-zinc-500" />
<textarea
v-model="description"
:placeholder="t('finance.add.description.placeholder')"
rows="3"
:disabled="isLoading"
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white placeholder:text-zinc-600 focus:border-purple-500/30 focus:outline-none transition-colors resize-none disabled:opacity-50"
/>
</div>
</div>
</div>
<div v-else key="photo" class="space-y-4">
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.photo.label') }} <span class="text-rose-400">*</span>
</label>
<label class="block">
<input
type="file"
accept="image/*"
@change="handlePhotoSelect"
:disabled="isLoading"
class="hidden"
/>
<div class="w-full aspect-[4/3] rounded-[16px] bg-[#16161F] border-2 border-dashed border-white/[0.1] hover:border-purple-500/30 transition-colors cursor-pointer flex flex-col items-center justify-center gap-3 relative overflow-hidden">
<div v-if="selectedPhoto" class="absolute inset-0 flex items-center justify-center bg-[#16161F]">
<div class="text-center p-4">
<Camera :size="32" :stroke-width="2" class="text-purple-400 mx-auto mb-2" />
<p class="text-white text-[13px] font-medium mb-1">{{ selectedPhoto.name }}</p>
<p class="text-zinc-500 text-[11px]">{{ t('finance.add.photo.change') }}</p>
</div>
</div>
<template v-else>
<Camera :size="40" :stroke-width="2" class="text-zinc-600" />
<div class="text-center">
<p class="text-zinc-400 text-[13px] font-medium mb-1">{{ t('finance.add.photo.upload') }}</p>
<p class="text-zinc-600 text-[11px]">{{ t('finance.add.photo.select') }}</p>
</div>
</template>
</div>
</label>
</div>
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.type') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
</label>
<div class="grid grid-cols-2 gap-3">
<button
type="button"
@click="handleTypeSelect('expense')"
:disabled="isLoading"
:class="[
'p-3 rounded-[14px] border transition-all',
transactionType === 'expense'
? 'bg-rose-500/10 border-rose-500/30'
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
]"
>
<ArrowDownCircle
:size="16"
:stroke-width="2"
:class="transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-500'"
class="mb-1 mx-auto"
/>
<p
:class="[
'text-[12px] font-semibold',
transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-400',
]"
>
{{ t('finance.add.type.expense') }}
</p>
</button>
<button
type="button"
@click="handleTypeSelect('income')"
:disabled="isLoading"
:class="[
'p-3 rounded-[14px] border transition-all',
transactionType === 'income'
? 'bg-emerald-500/10 border-emerald-500/30'
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
]"
>
<ArrowUpCircle
:size="16"
:stroke-width="2"
:class="transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-500'"
class="mb-1 mx-auto"
/>
<p
:class="[
'text-[12px] font-semibold',
transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-400',
]"
>
{{ t('finance.add.type.income') }}
</p>
</button>
</div>
</div>
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.category') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
</label>
<select
v-model="selectedCategory"
:disabled="isLoading"
class="w-full px-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white focus:border-purple-500/30 focus:outline-none transition-colors appearance-none disabled:opacity-50"
>
<option value="" class="bg-[#16161F]">{{ t('finance.add.category.select') }}</option>
<option v-for="category in availableCategories" :key="category.id" :value="category.id" class="bg-[#16161F]">
{{ t(category.label) }}
</option>
</select>
</div>
<div>
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
{{ t('finance.add.description') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
</label>
<div class="relative">
<FileText :size="20" :stroke-width="2" class="absolute left-4 top-4 text-zinc-500" />
<textarea
v-model="description"
:placeholder="t('finance.add.description.placeholder')"
rows="3"
:disabled="isLoading"
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white placeholder:text-zinc-600 focus:border-purple-500/30 focus:outline-none transition-colors resize-none disabled:opacity-50"
/>
</div>
</div>
</div>
</Transition>
</main>
<div class="px-5 py-4 border-t border-white/[0.06]">
<div class="flex gap-3">
<button
type="button"
@click="emit('close')"
:disabled="isLoading"
class="flex-1 py-3.5 rounded-[14px] bg-[#16161F] hover:bg-[#1A1A24] border border-white/[0.06] text-zinc-400 text-[14px] font-semibold transition-all disabled:opacity-50"
>
{{ t('finance.add.cancel') }}
</button>
<button
type="button"
@click="handleSubmit"
:disabled="isLoading"
class="flex-1 py-3.5 rounded-[14px] bg-gradient-to-br from-purple-500 to-blue-600 hover:shadow-lg hover:shadow-purple-500/30 text-white text-[14px] font-semibold transition-all active:scale-95 disabled:opacity-50 flex items-center justify-center gap-2"
>
<Loader2 v-if="isLoading" class="h-4 w-4 animate-spin" />
{{ isLoading ? t('finance.add.submitting') : t('finance.add.submit') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
X,
Camera,
Receipt,
Edit3,
Calendar,
DollarSign,
FileText,
ArrowDownCircle,
ArrowUpCircle,
Loader2,
} from 'lucide-vue-next'
import { createTransaction, createTransactionFromPhoto } from '@/api/transactions'
import { useI18n } from '@/i18n'
type TabType = 'manual' | 'receipt' | 'photo'
type TransactionType = 'income' | 'expense'
type OptionalTransactionType = TransactionType | ''
interface CategoryOption {
id: string
label: string
types: TransactionType[]
}
const props = defineProps<{
familyId?: number
userId?: number
}>()
const emit = defineEmits<{
close: []
success: []
}>()
const { t } = useI18n()
const activeTab = ref<TabType>('manual')
const transactionType = ref<OptionalTransactionType>('expense')
const selectedCategory = ref('')
const amount = ref('')
const datetime = ref('')
const description = ref('')
const receiptNumber = ref('')
const receiptDate = ref('')
const selectedPhoto = ref<File | null>(null)
const isLoading = ref(false)
const errorMessage = ref('')
const tabs = [
{ id: 'manual' as TabType, label: 'finance.add.tab.manual', icon: Edit3 },
{ id: 'receipt' as TabType, label: 'finance.add.tab.receipt', icon: Receipt },
{ id: 'photo' as TabType, label: 'finance.add.tab.photo', icon: Camera },
]
const categories: CategoryOption[] = [
{ id: 'groceries', label: 'finance.category.groceries', types: ['expense'] },
{ id: 'shopping', label: 'finance.categories.shopping', types: ['expense'] },
{ id: 'transport', label: 'finance.categories.transport', types: ['expense'] },
{ id: 'housing', label: 'finance.categories.housing', types: ['expense'] },
{ id: 'entertainment', label: 'finance.categories.entertainment', types: ['expense'] },
{ id: 'bills_utilities', label: 'finance.categories.billsUtilities', types: ['expense'] },
{ id: 'healthcare', label: 'finance.categories.healthcare', types: ['expense'] },
{ id: 'other', label: 'finance.categories.others', types: ['expense'] },
{ id: 'income', label: 'finance.category.income', types: ['income'] },
{ id: 'work', label: 'finance.categories.work', types: ['income'] },
{ id: 'gifts_donations', label: 'finance.categories.giftsDonations', types: ['income'] },
]
const availableCategories = computed(() => {
if (!transactionType.value) {
return categories
}
return categories.filter((category) => category.types.includes(transactionType.value as TransactionType))
})
const resolvedFamilyId = computed(() => {
if (typeof props.familyId === 'number' && Number.isFinite(props.familyId) && props.familyId > 0) {
return props.familyId
}
return null
})
onMounted(() => {
const now = new Date()
const offset = now.getTimezoneOffset() * 60000
datetime.value = new Date(now.getTime() - offset).toISOString().slice(0, 16)
receiptDate.value = new Date().toISOString().split('T')[0]
})
watch(activeTab, (tab) => {
errorMessage.value = ''
if (tab === 'manual' && !transactionType.value) {
transactionType.value = 'expense'
}
})
watch(transactionType, () => {
if (!availableCategories.value.some((category) => category.id === selectedCategory.value)) {
selectedCategory.value = ''
}
})
const handlePhotoSelect = (event: Event) => {
const target = event.target as HTMLInputElement
selectedPhoto.value = target.files?.[0] ?? null
}
const handleTypeSelect = (type: TransactionType) => {
if (activeTab.value === 'manual') {
transactionType.value = type
return
}
transactionType.value = transactionType.value === type ? '' : type
}
const handleSubmit = async () => {
if (!resolvedFamilyId.value) {
errorMessage.value = t('finance.add.error.missingFamily')
return
}
isLoading.value = true
errorMessage.value = ''
try {
if (activeTab.value === 'manual') {
const parsedAmount = Number.parseFloat(amount.value)
if (!transactionType.value || !selectedCategory.value || !datetime.value || !Number.isFinite(parsedAmount) || parsedAmount <= 0) {
throw new Error(t('finance.add.error.missingFields'))
}
await createTransaction({
family_id: resolvedFamilyId.value,
type: transactionType.value,
category: selectedCategory.value,
amount: parsedAmount,
datetime: new Date(datetime.value).toISOString(),
description: description.value.trim() || undefined,
})
} else if (activeTab.value === 'receipt') {
if (!receiptNumber.value.trim() || !receiptDate.value) {
throw new Error(t('finance.add.error.missingFields'))
}
await createTransaction({
family_id: resolvedFamilyId.value,
receipt_number: receiptNumber.value.trim(),
receipt_date: receiptDate.value,
type: transactionType.value || undefined,
category: selectedCategory.value || undefined,
description: description.value.trim() || undefined,
})
} else {
if (!selectedPhoto.value) {
throw new Error(t('finance.add.error.missingPhoto'))
}
await createTransactionFromPhoto({
photo: selectedPhoto.value,
family_id: resolvedFamilyId.value,
type: transactionType.value || undefined,
category: selectedCategory.value || undefined,
description: description.value.trim() || undefined,
})
}
emit('success')
emit('close')
} catch (error: unknown) {
errorMessage.value = error instanceof Error && error.message
? error.message
: t('finance.add.error.generic')
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+315
View File
@@ -0,0 +1,315 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { Calendar, Plus, Clock, Users, Heart, Utensils, Film } from 'lucide-vue-next'
import HeaderWidget from './HeaderWidget.vue'
import Navigation from './Navigation.vue'
import { useI18n } from '@/i18n';
const { t } = useI18n();
interface Event {
id: number
title: string
time: string
endTime: string
type: 'family' | 'couple' | 'kids' | 'personal'
icon: any
color: string
attendees?: string[]
}
const emit = defineEmits<{
navigate: [screen: string]
}>()
const today = 9
const selectedDate = ref(today)
const scrollContainerRef = ref<HTMLDivElement | null>(null)
const todayEvents: Event[] = [
{
id: 1,
title: 'Morning Yoga',
time: '07:00',
endTime: '07:45',
type: 'personal',
icon: Heart,
color: 'pink',
},
{
id: 2,
title: 'Kids School Drop',
time: '08:30',
endTime: '09:00',
type: 'kids',
icon: Users,
color: 'blue',
attendees: ['Emma', 'Jack'],
},
{
id: 3,
title: 'Family Lunch',
time: '12:30',
endTime: '13:30',
type: 'family',
icon: Utensils,
color: 'orange',
attendees: ['Everyone'],
},
{
id: 4,
title: 'Movie Night',
time: '19:00',
endTime: '21:00',
type: 'family',
icon: Film,
color: 'purple',
attendees: ['Everyone'],
},
{
id: 5,
title: 'Date Night Dinner',
time: '20:00',
endTime: '22:00',
type: 'couple',
icon: Heart,
color: 'rose',
attendees: ['Sarah', 'Mike'],
},
]
const upcomingEvents = [
{ date: 'Tomorrow', title: 'Soccer Practice', time: '16:00', type: 'kids', color: 'blue' },
{ date: 'Saturday', title: 'Family BBQ', time: '18:00', type: 'family', color: 'orange' },
{ date: 'Sunday', title: 'Park Picnic', time: '11:00', type: 'family', color: 'emerald' },
{ date: 'Monday', title: 'Anniversary Dinner', time: '19:30', type: 'couple', color: 'rose' },
]
const colorMap: Record<string, { bg: string; text: string; border: string }> = {
orange: { bg: 'bg-orange-500/10', text: 'text-orange-400', border: 'border-orange-500/20' },
purple: { bg: 'bg-purple-500/10', text: 'text-purple-400', border: 'border-purple-500/20' },
blue: { bg: 'bg-blue-500/10', text: 'text-blue-400', border: 'border-blue-500/20' },
rose: { bg: 'bg-rose-500/10', text: 'text-rose-400', border: 'border-rose-500/20' },
pink: { bg: 'bg-pink-500/10', text: 'text-pink-400', border: 'border-pink-500/20' },
emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400', border: 'border-emerald-500/20' },
}
const dates = computed(() => {
return Array.from({ length: 14 }, (_, i) => {
const offset = i - 7
const day = today + offset
return {
day: day,
weekday: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][(2 + day) % 7],
isToday: day === today,
}
})
})
onMounted(() => {
if (scrollContainerRef.value) {
const container = scrollContainerRef.value
const todayIndex = dates.value.findIndex(d => d.isToday)
const buttonWidth = 56 + 8
const scrollPosition = todayIndex * buttonWidth - container.clientWidth / 2 + buttonWidth / 2
setTimeout(() => {
container.scrollTo({ left: scrollPosition, behavior: 'smooth' })
}, 100)
}
})
function calculateDuration(start: string, end: string): string {
const [startHour, startMin] = start.split(':').map(Number)
const [endHour, endMin] = end.split(':').map(Number)
const startMinutes = startHour * 60 + startMin
const endMinutes = endHour * 60 + endMin
const duration = endMinutes - startMinutes
const hours = Math.floor(duration / 60)
const minutes = duration % 60
if (hours === 0) return `${minutes}m`
if (minutes === 0) return `${hours}h`
return `${hours}h ${minutes}m`
}
</script>
<template>
<div class="min-h-screen bg-[#0A0A0F] dark">
<div class="mx-auto max-w-md min-h-screen flex flex-col relative">
<HeaderWidget
:icon="Calendar"
:eyebrow="t('calendar.header.eyebrow')"
:title="t('calendar.header.title')"
@navigate="emit('navigate', $event)"
/>
<!-- Date Strip with Month -->
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 100 } }"
class="mx-5 mb-4 rounded-[20px] bg-gradient-to-br from-purple-600/20 via-purple-500/10 to-blue-600/20 p-5 border border-purple-500/20 relative overflow-hidden"
>
<!-- Decorative elements -->
<div class="absolute -top-10 -right-10 w-32 h-32 bg-purple-500 rounded-full blur-3xl opacity-10" />
<div class="absolute -bottom-8 -left-8 w-24 h-24 bg-blue-500 rounded-full blur-3xl opacity-10" />
<div class="relative z-10">
<!-- Month Label -->
<div class="flex items-center justify-center mb-4">
<div class="px-4 py-1.5 rounded-full bg-white/5 backdrop-blur-sm border border-white/10">
<p class="text-purple-200 text-[12px] font-medium">May 2026</p>
</div>
</div>
<!-- Horizontal Date Strip -->
<div class="relative -mx-5 px-5">
<div ref="scrollContainerRef" class="flex gap-2 overflow-x-auto scrollbar-hide pb-1" style="scroll-behavior: smooth">
<button
v-for="(date, index) in dates"
:key="index"
@click="selectedDate = date.day"
:class="[
'flex-shrink-0 w-14 py-3 rounded-[14px] transition-all relative',
selectedDate === date.day
? 'bg-gradient-to-br from-purple-500 to-blue-600 shadow-lg shadow-purple-500/30'
: date.isToday
? 'bg-white/10 backdrop-blur-sm border border-white/20'
: 'bg-white/5 backdrop-blur-sm border border-white/10 hover:bg-white/10 hover:border-white/20'
]"
>
<div
v-if="date.isToday && selectedDate !== date.day"
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-purple-400 shadow-lg shadow-purple-500/50"
/>
<p
:class="[
'text-[11px] font-medium mb-1',
selectedDate === date.day ? 'text-white' : date.isToday ? 'text-purple-200' : 'text-purple-300/60'
]"
>
{{ date.weekday }}
</p>
<p
:class="[
'text-[18px] font-bold',
selectedDate === date.day ? 'text-white' : date.isToday ? 'text-white' : 'text-white/60'
]"
>
{{ date.day }}
</p>
<div
v-if="selectedDate === date.day"
class="w-1.5 h-1.5 rounded-full bg-white mx-auto mt-1.5 shadow-lg shadow-white/50"
/>
</button>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<main class="flex-1 overflow-y-auto px-5 pb-28">
<!-- Today's Timeline -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-white text-[16px] font-semibold">Today's Schedule</h2>
<span class="text-zinc-500 text-[12px] font-medium">{{ todayEvents.length }} events</span>
</div>
<div class="space-y-3">
<div
v-for="(event, index) in todayEvents"
:key="event.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0, transition: { delay: index * 50 } }"
:class="[
'rounded-[16px] p-4 border hover:scale-[1.02] transition-all cursor-pointer group',
colorMap[event.color].bg,
colorMap[event.color].border
]"
>
<div class="flex items-start gap-3">
<!-- Time -->
<div class="flex-shrink-0 pt-0.5">
<p :class="['text-[13px] font-bold', colorMap[event.color].text]">
{{ event.time }}
</p>
<p class="text-zinc-600 text-[11px] mt-0.5">{{ event.endTime }}</p>
</div>
<!-- Icon -->
<div
:class="[
'w-10 h-10 rounded-[12px] border flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform',
colorMap[event.color].bg,
colorMap[event.color].border
]"
>
<component :is="event.icon" :size="18" :stroke-width="2" :class="colorMap[event.color].text" />
</div>
<!-- Details -->
<div class="flex-1 min-w-0">
<h3 class="text-white text-[14px] font-semibold mb-1">{{ event.title }}</h3>
<div v-if="event.attendees" class="flex items-center gap-1.5">
<Users :size="12" :stroke-width="2" class="text-zinc-500" />
<p class="text-zinc-500 text-[12px]">{{ event.attendees.join(', ') }}</p>
</div>
</div>
<!-- Duration indicator -->
<div class="flex items-center gap-1 px-2 py-1 bg-white/[0.05] rounded-lg">
<Clock :size="12" :stroke-width="2" class="text-zinc-500" />
<span class="text-zinc-500 text-[11px] font-medium">
{{ calculateDuration(event.time, event.endTime) }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Upcoming Events -->
<div>
<h2 class="text-white text-[16px] font-semibold mb-3">Upcoming</h2>
<div class="space-y-2">
<div
v-for="(event, index) in upcomingEvents"
:key="index"
v-motion
:initial="{ opacity: 0, x: -10 }"
:enter="{ opacity: 1, x: 0, transition: { delay: 100 + index * 40 } }"
class="rounded-[14px] bg-[#16161F] p-3.5 border border-white/[0.06] hover:border-white/[0.1] hover:bg-[#1A1A24] transition-all cursor-pointer flex items-center gap-3"
>
<div :class="['w-2 h-12 rounded-full', colorMap[event.color].bg]" />
<div class="flex-1">
<h4 class="text-white text-[13px] font-semibold mb-0.5">{{ event.title }}</h4>
<p class="text-zinc-500 text-[12px]">{{ event.date }} at {{ event.time }}</p>
</div>
<div :class="['w-2 h-2 rounded-full', colorMap[event.color].text.replace('text-', 'bg-')]" />
</div>
</div>
</div>
</main>
<!-- Floating Add Button -->
<button
v-motion
:initial="{ scale: 0 }"
:enter="{ scale: 1, transition: { delay: 300, type: 'spring', bounce: 0.5 } }"
class="fixed bottom-32 right-6 w-14 h-14 rounded-full bg-gradient-to-br from-purple-500 to-blue-600 shadow-[0_8px_32px_rgba(139,92,246,0.4)] hover:shadow-[0_12px_40px_rgba(139,92,246,0.6)] flex items-center justify-center transition-all active:scale-95 group z-10"
>
<Plus :size="24" :stroke-width="2.5" class="text-white group-hover:rotate-90 transition-transform duration-300" />
</button>
<!-- Bottom Navigation -->
<Navigation active-screen="calendar" @navigate="emit('navigate', $event)" />
</div>
</div>
</template>
+1 -1
View File
@@ -13,7 +13,7 @@ import {
Utensils,
Zap,
} from 'lucide-vue-next';
import { useI18n } from '../i18n';
import { useI18n } from '@/i18n';
const { t } = useI18n();
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Eye, EyeOff, TrendingUp } from 'lucide-vue-next';
import { useI18n } from '../i18n';
import { useI18n } from '@/i18n';
const isVisible = ref(true);
const chartData = [20000, 21500, 20800, 23200, 22500, 24850];
+39 -27
View File
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Bell, Plus, Settings, Wallet as WalletIcon } from 'lucide-vue-next';
import { Plus, Wallet as WalletIcon } from 'lucide-vue-next';
import FinanceBalanceCard from './FinanceBalanceCard.vue';
import TransactionsList from './TransactionsList.vue';
import AnalyticsView from './AnalyticsView.vue';
import CategoriesView from './CategoriesView.vue';
import Navigation from './Navigation.vue';
import { useI18n } from '../i18n';
import HeaderWidget from './HeaderWidget.vue';
import { useI18n } from '@/i18n';
import AddTransactionScreen from "@/components/AddTransactionScreen.vue";
import TransactionDetailScreen from "@/components/TransactionDetailScreen.vue";
import type { Transaction } from '@/types/transaction'
type Tab = 'transactions' | 'analytics' | 'categories';
@@ -16,11 +20,19 @@ const emit = defineEmits<{
defineProps<{
familyId?: number;
userId?: number;
}>();
const { t } = useI18n();
const activeTab = ref<Tab>('transactions');
const showAddTransaction = ref(false);
const selectedTransaction = ref<Transaction | null>(null);
const transactionsListKey = ref(0);
const handleTransactionSuccess = () => {
transactionsListKey.value++;
};
const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
{ id: 'transactions', label: t('finance.tab.transactions') },
@@ -30,31 +42,30 @@ const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
</script>
<template>
<div class="min-h-screen bg-[#0A0A0F] dark">
<!-- Transaction Detail Screen -->
<TransactionDetailScreen
v-if="selectedTransaction"
:transaction="selectedTransaction"
@close="selectedTransaction = null"
/>
<!-- Add Transaction Screen -->
<AddTransactionScreen
v-else-if="showAddTransaction"
:family-id="familyId"
:user-id="userId"
@close="showAddTransaction = false"
@success="handleTransactionSuccess"
/>
<div v-else class="min-h-screen bg-[#0A0A0F] dark">
<div class="mx-auto flex min-h-screen max-w-md flex-col relative">
<header class="flex items-center justify-between px-5 pt-6 pb-4">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 items-center justify-center rounded-[16px] bg-gradient-to-br from-purple-500 to-blue-600 shadow-lg shadow-purple-500/20">
<WalletIcon class="h-5 w-5 text-white" :stroke-width="2.5" />
</div>
<div>
<p class="mb-0.5 text-[11px] font-normal text-zinc-500">{{ t('finance.header.eyebrow') }}</p>
<h1 class="text-[17px] font-semibold tracking-tight text-white">{{ t('finance.header.title') }}</h1>
</div>
</div>
<div class="flex items-center gap-2">
<button type="button" class="flex h-10 w-10 items-center justify-center rounded-[14px] border border-white/5 bg-[#1A1A24] transition-colors hover:bg-[#222230]">
<Bell class="h-[18px] w-[18px] text-zinc-400" :stroke-width="2" />
</button>
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-[14px] border border-white/5 bg-[#1A1A24] transition-colors hover:bg-[#222230]"
@click="emit('navigate', 'settings')"
>
<Settings class="h-[18px] w-[18px] text-zinc-400" :stroke-width="2" />
</button>
</div>
</header>
<HeaderWidget
:icon="WalletIcon"
:eyebrow="t('finance.header.eyebrow')"
:title="t('finance.header.title')"
@navigate="emit('navigate', $event)"
/>
<main class="flex-1 overflow-y-auto px-5 pb-24">
<div class="mb-5">
@@ -81,13 +92,14 @@ const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
</div>
</div>
<TransactionsList v-if="activeTab === 'transactions'" :family-id="familyId" />
<TransactionsList v-if="activeTab === 'transactions'" :family-id="familyId" :key="transactionsListKey" />
<AnalyticsView v-else-if="activeTab === 'analytics'" />
<CategoriesView v-else />
</main>
<button
type="button"
@click="showAddTransaction = true"
class="group fixed bottom-24 right-6 z-10 flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-blue-600 shadow-[0_8px_32px_rgba(139,92,246,0.4)] transition-all hover:shadow-[0_12px_40px_rgba(139,92,246,0.6)] active:scale-95"
>
<Plus class="h-6 w-6 text-white transition-transform duration-300 group-hover:rotate-90" :stroke-width="2.5" />
@@ -1,64 +1,27 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { Bell, Settings, User } from 'lucide-vue-next';
import { useI18n } from '@/i18n';
import type { Component } from 'vue';
import { Bell, Settings } from 'lucide-vue-next';
defineProps<{
icon: Component;
eyebrow: string;
title: string;
}>();
const emit = defineEmits<{
navigate: [screen: string];
}>();
const props = withDefaults(defineProps<{
familyName?: string;
}>(), {
familyName: '',
});
const { t } = useI18n();
const currentHour = ref(new Date().getHours());
let timerId: number | undefined;
const greeting = computed(() => {
if (currentHour.value < 5) {
return t('header.greeting.night');
}
if (currentHour.value < 12) {
return t('header.greeting.morning');
}
if (currentHour.value < 18) {
return t('header.greeting.afternoon');
}
return t('header.greeting.evening');
});
function updateCurrentHour() {
currentHour.value = new Date().getHours();
}
onMounted(() => {
updateCurrentHour();
timerId = window.setInterval(updateCurrentHour, 60_000);
});
onBeforeUnmount(() => {
if (timerId !== undefined) {
window.clearInterval(timerId);
}
});
</script>
<template>
<header class="flex items-center justify-between px-5 pt-6 pb-4">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 items-center justify-center rounded-[16px] bg-gradient-to-br from-purple-500 to-blue-600 shadow-lg shadow-purple-500/20">
<User class="h-5 w-5 text-white" :stroke-width="2.5" />
<component :is="icon" class="h-5 w-5 text-white" :stroke-width="2.5" />
</div>
<div>
<p class="mb-0.5 text-[11px] font-normal text-zinc-500">{{ greeting }}</p>
<h1 class="text-[17px] font-semibold tracking-tight text-white">{{ props.familyName || t('header.familyName') }}</h1>
<p class="mb-0.5 text-[11px] font-normal text-zinc-500">{{ eyebrow }}</p>
<h1 class="text-[17px] font-semibold tracking-tight text-white">{{ title }}</h1>
</div>
</div>
<div class="flex items-center gap-2">
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import {User} from 'lucide-vue-next'
import Navigation from './Navigation.vue'
import BalanceWidget from './BalanceWidget.vue'
import TodayWidget from './TodayWidget.vue'
import RecentActivityWidget from './RecentActivityWidget.vue'
import SwipeCards from "@/components/SwipeCards.vue";
import HeaderWidget from "@/components/HeaderWidget.vue";
import {useI18n} from "@/i18n";
import {computed, onBeforeUnmount, onMounted, ref} from "vue";
const {t} = useI18n();
const currentHour = ref(new Date().getHours());
let timerId: number | undefined;
const userName = "Alex Belyan";
const greeting = computed(() => {
if (currentHour.value < 5) {
return t('header.greeting.night');
}
if (currentHour.value < 12) {
return t('header.greeting.morning');
}
if (currentHour.value < 18) {
return t('header.greeting.afternoon');
}
return t('header.greeting.evening');
});
function updateCurrentHour() {
currentHour.value = new Date().getHours();
}
onMounted(() => {
updateCurrentHour();
timerId = window.setInterval(updateCurrentHour, 60_000);
});
onBeforeUnmount(() => {
if (timerId !== undefined) {
window.clearInterval(timerId);
}
});
const emit = defineEmits<{
navigate: [screen: string]
}>()
</script>
<template>
<div class="min-h-screen bg-[#0A0A0F] dark">
<div class="mx-auto max-w-md min-h-screen flex flex-col relative">
<!-- Header -->
<HeaderWidget
:icon="User"
:eyebrow=greeting
:title=userName
@navigate="emit('navigate', $event)"
/>
<!-- Main Content -->
<main class="flex-1 px-5 pb-32 overflow-y-auto">
<div class="space-y-4">
<!-- Balance Widget -->
<BalanceWidget/>
<!-- Today Widget -->
<TodayWidget/>
<!-- Swipe cards Widget -->
<SwipeCards/>
<!-- Recent Activity Widget -->
<RecentActivityWidget/>
</div>
</main>
<!-- Bottom Navigation -->
<Navigation active-screen="home" @navigate="emit('navigate', $event)"/>
</div>
</div>
</template>
+310
View File
@@ -0,0 +1,310 @@
<template>
<div class="min-h-screen bg-[#0A0A0F] dark">
<div class="mx-auto max-w-md min-h-screen flex flex-col relative">
<!-- Header -->
<header
v-motion
:initial="{ opacity: 0, y: -20 }"
:enter="{ opacity: 1, y: 0 }"
class="flex items-center justify-between px-5 pt-6 pb-4"
>
<div class="flex items-center gap-3">
<div class="w-11 h-11 rounded-[16px] bg-gradient-to-br from-rose-500 to-amber-600 flex items-center justify-center shadow-lg shadow-rose-500/20">
<Heart :size="20" :stroke-width="2.5" class="text-white" />
</div>
<div>
<p class="text-[11px] text-zinc-500 font-normal mb-0.5">Connect & grow</p>
<h1 class="text-[17px] text-white font-semibold tracking-tight">Intimacy</h1>
</div>
</div>
<div class="flex items-center gap-2">
<button class="w-10 h-10 rounded-[14px] bg-[#1A1A24] flex items-center justify-center hover:bg-[#222230] transition-colors border border-white/5">
<Bell :size="18" :stroke-width="2" class="text-zinc-400" />
</button>
<button
@click="emit('navigate', 'settings')"
class="w-10 h-10 rounded-[14px] bg-[#1A1A24] flex items-center justify-center hover:bg-[#222230] transition-colors border border-white/5"
>
<Settings :size="18" :stroke-width="2" class="text-zinc-400" />
</button>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 overflow-y-auto px-5 pb-28">
<div class="space-y-5">
<!-- Mood Check-in -->
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 100 } }"
>
<h2 class="text-white text-[15px] font-semibold mb-3 flex items-center gap-2">
<Smile :size="16" :stroke-width="2" class="text-rose-400" />
How are you feeling?
</h2>
<div class="grid grid-cols-5 gap-2">
<button
v-for="(mood, index) in moods"
:key="mood.label"
v-motion
:initial="{ opacity: 0, scale: 0.8 }"
:enter="{ opacity: 1, scale: 1, transition: { delay: 150 + index * 50 } }"
@click="selectedMood = mood.label"
:class="[
'relative aspect-square rounded-[16px] bg-gradient-to-br p-3 flex flex-col items-center justify-center gap-1 transition-all active:scale-95',
mood.color,
selectedMood === mood.label
? 'shadow-[0_8px_24px_rgba(0,0,0,0.4)] scale-105'
: 'shadow-[0_4px_16px_rgba(0,0,0,0.2)]'
]"
:style="selectedMood === mood.label ? getMoodShadow(mood.glow) : undefined"
>
<component :is="mood.icon" :size="20" :stroke-width="2.5" class="text-white" />
<span class="text-[9px] text-white font-medium">{{ mood.label }}</span>
</button>
</div>
</div>
<!-- Scratch Cards -->
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 200 } }"
>
<h2 class="text-white text-[15px] font-semibold mb-3 flex items-center gap-2">
<Gift :size="16" :stroke-width="2" class="text-amber-400" />
Surprise Activities
</h2>
<div class="grid grid-cols-3 gap-3">
<div
v-for="(card, index) in scratchCards"
:key="card.id"
v-motion
:initial="{ opacity: 0, y: 10 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 250 + index * 50 } }"
class="aspect-square rounded-[18px] bg-gradient-to-br from-amber-600/20 via-orange-500/10 to-rose-600/20 p-4 border border-amber-500/20 flex flex-col items-center justify-center gap-2 relative overflow-hidden cursor-pointer hover:scale-105 transition-transform active:scale-95"
>
<div class="absolute inset-0 bg-gradient-to-br from-amber-500/10 to-rose-500/10 backdrop-blur-sm" />
<component :is="card.icon" :size="24" :stroke-width="2" class="text-amber-300 relative z-10" />
<p class="text-[11px] text-amber-200 font-medium text-center relative z-10">{{ card.title }}</p>
</div>
</div>
</div>
<!-- Ideas Jar -->
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 300 } }"
class="rounded-[20px] bg-gradient-to-br from-rose-600/20 via-pink-500/10 to-purple-600/20 p-5 border border-rose-500/20 relative overflow-hidden"
>
<div class="absolute -top-10 -right-10 w-32 h-32 bg-rose-500 rounded-full blur-3xl opacity-10" />
<div class="absolute -bottom-8 -left-8 w-24 h-24 bg-purple-500 rounded-full blur-3xl opacity-10" />
<div class="relative z-10">
<div class="flex items-center justify-between mb-4">
<h2 class="text-white text-[15px] font-semibold flex items-center gap-2">
<Lightbulb :size="16" :stroke-width="2" class="text-rose-400" />
Ideas Jar
</h2>
<button class="w-8 h-8 rounded-full bg-white/10 backdrop-blur-sm border border-white/20 flex items-center justify-center hover:bg-white/20 transition-colors">
<Plus :size="16" :stroke-width="2" class="text-white" />
</button>
</div>
<Transition mode="out-in">
<div
v-if="randomIdea"
key="idea"
class="mb-4 p-4 rounded-[16px] bg-white/10 backdrop-blur-sm border border-white/20"
>
<p class="text-white text-[14px] text-center font-medium">{{ randomIdea }}</p>
</div>
<div
v-else
key="placeholder"
class="mb-4 p-4 rounded-[16px] bg-white/5 backdrop-blur-sm border border-white/10"
>
<p class="text-zinc-500 text-[13px] text-center">Tap below to get a random idea</p>
</div>
</Transition>
<button
@click="pickRandomIdea"
class="w-full py-3 rounded-[14px] bg-gradient-to-br from-rose-500 to-pink-600 shadow-lg shadow-rose-500/30 hover:shadow-rose-500/50 transition-all active:scale-95 flex items-center justify-center gap-2"
>
<Sparkles :size="16" :stroke-width="2" class="text-white" />
<span class="text-white text-[13px] font-semibold">Pick Random Idea</span>
</button>
</div>
</div>
<!-- Memories -->
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 350 } }"
>
<div class="flex items-center justify-between mb-3">
<h2 class="text-white text-[15px] font-semibold flex items-center gap-2">
<Camera :size="16" :stroke-width="2" class="text-purple-400" />
Memories
</h2>
<button class="text-purple-400 text-[12px] font-medium hover:text-purple-300 transition-colors">
View All
</button>
</div>
<div class="grid grid-cols-3 gap-3 mb-1">
<div
v-for="(memory, index) in memories"
:key="memory.id"
v-motion
:initial="{ opacity: 0, scale: 0.9 }"
:enter="{ opacity: 1, scale: 1, transition: { delay: 400 + index * 50 } }"
class="aspect-square rounded-[16px] bg-gradient-to-br from-purple-600/20 to-indigo-600/20 border border-purple-500/20 p-2.5 flex flex-col items-center justify-center gap-1.5 cursor-pointer hover:scale-105 transition-transform active:scale-95 relative overflow-hidden"
>
<div class="absolute inset-0 bg-gradient-to-br from-purple-500/10 to-indigo-500/10 backdrop-blur-sm" />
<span class="text-2xl relative z-10">{{ memory.image }}</span>
<div class="text-center relative z-10 w-full px-1">
<p class="text-white text-[10px] font-medium truncate">{{ memory.title }}</p>
<p class="text-purple-300 text-[9px]">{{ memory.date }}</p>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 400 } }"
class="grid grid-cols-2 gap-3 mb-2"
>
<button class="rounded-[16px] bg-[#16161F] hover:bg-[#1A1A24] border border-white/[0.06] hover:border-white/[0.1] p-4 transition-all active:scale-95 flex items-center gap-3">
<div class="w-11 h-11 rounded-[13px] bg-gradient-to-br from-rose-500/10 to-pink-500/10 border border-rose-500/20 flex items-center justify-center">
<Calendar :size="20" :stroke-width="2" class="text-rose-400" />
</div>
<div class="flex-1 text-left">
<p class="text-white text-[13px] font-semibold">Private</p>
<p class="text-zinc-500 text-[11px]">Calendar</p>
</div>
</button>
<button class="rounded-[16px] bg-[#16161F] hover:bg-[#1A1A24] border border-white/[0.06] hover:border-white/[0.1] p-4 transition-all active:scale-95 flex items-center gap-3">
<div class="w-11 h-11 rounded-[13px] bg-gradient-to-br from-amber-500/10 to-orange-500/10 border border-amber-500/20 flex items-center justify-center">
<Flame :size="20" :stroke-width="2" class="text-amber-400" />
</div>
<div class="flex-1 text-left">
<p class="text-white text-[13px] font-semibold">Connection</p>
<p class="text-zinc-500 text-[11px]">Score</p>
</div>
</button>
</div>
</div>
</main>
<!-- Floating Add Button -->
<button
v-motion
:initial="{ scale: 0 }"
:enter="{ scale: 1, transition: { delay: 500, type: 'spring', bounce: 0.5 } }"
class="fixed bottom-32 right-6 w-14 h-14 rounded-full bg-gradient-to-br from-rose-500 to-pink-600 shadow-[0_8px_32px_rgba(244,63,94,0.4)] hover:shadow-[0_12px_40px_rgba(244,63,94,0.6)] flex items-center justify-center transition-all active:scale-95 group z-10"
>
<Plus :size="24" :stroke-width="2.5" class="text-white group-hover:rotate-90 transition-transform duration-300" />
</button>
<!-- Bottom Navigation -->
<Navigation active-screen="intimacy" @navigate="emit('navigate', $event)" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import {
Heart,
Lightbulb,
Sparkles,
Gift,
Bell,
Settings,
Plus,
Smile,
Coffee,
Moon,
Sun,
Flame,
Camera,
Calendar,
} from 'lucide-vue-next'
import Navigation from './Navigation.vue'
const emit = defineEmits<{
navigate: [screen: string]
}>()
const moods = [
{ icon: Sun, label: 'Energized', color: 'from-amber-500 to-orange-500', glow: 'amber' },
{ icon: Heart, label: 'Loving', color: 'from-rose-500 to-pink-500', glow: 'rose' },
{ icon: Coffee, label: 'Cozy', color: 'from-amber-600 to-orange-700', glow: 'orange' },
{ icon: Sparkles, label: 'Playful', color: 'from-purple-400 to-pink-400', glow: 'purple' },
{ icon: Moon, label: 'Calm', color: 'from-indigo-500 to-purple-600', glow: 'indigo' },
]
const scratchCards = [
{ id: 1, title: 'Date Night', icon: Sparkles },
{ id: 2, title: 'Surprise Activity', icon: Gift },
{ id: 3, title: 'Quality Time', icon: Heart },
]
const memories = [
{ id: 1, title: 'Paris Weekend', date: 'March 2026', image: '🗼' },
{ id: 2, title: 'Cooking Together', date: 'April 2026', image: '👨‍🍳' },
{ id: 3, title: 'Beach Sunset', date: 'May 2026', image: '🌅' },
]
const ideas = [
'Cook a new recipe together',
'Stargazing picnic',
'Dance in the living room',
'Write love letters',
'Take a cooking class',
'Movie marathon night',
'Couples massage at home',
'Plan a surprise date',
]
const selectedMood = ref<string | null>(null)
const randomIdea = ref<string | null>(null)
function pickRandomIdea() {
const randomIndex = Math.floor(Math.random() * ideas.length)
randomIdea.value = ideas[randomIndex]
}
function getMoodShadow(glow: string): { boxShadow: string } {
const glowColors: Record<string, string> = {
rose: '244,63,94',
amber: '251,191,36',
purple: '168,85,247',
orange: '249,115,22',
indigo: '99,102,241',
}
const rgb = glowColors[glow] || '168,85,247'
return { boxShadow: `0 8px 24px rgba(${rgb}, 0.4)` }
}
</script>
<style scoped>
.v-enter-active,
.v-leave-active {
transition: opacity 0.3s, scale 0.3s;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
scale: 0.9;
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import {Calendar, Heart, Home, Sparkles, Wallet} from 'lucide-vue-next';
import { computed, type Component } from 'vue';
import { useI18n } from '../i18n';
import { useI18n } from '@/i18n';
interface NavItem {
icon: Component;
@@ -0,0 +1,259 @@
<template>
<div class="min-h-screen bg-[#0A0A0F] dark">
<div class="mx-auto max-w-md min-h-screen flex flex-col relative">
<!-- Header -->
<header
v-motion
:initial="{ opacity: 0, y: -20 }"
:enter="{ opacity: 1, y: 0 }"
class="flex items-center justify-between px-5 pt-6 pb-4"
>
<div class="flex items-center gap-3">
<div
:class="[
'w-11 h-11 rounded-[16px] bg-gradient-to-br flex items-center justify-center shadow-lg',
transaction.type === 'income'
? 'from-emerald-500 to-green-600 shadow-emerald-500/20'
: 'from-rose-500 to-red-600 shadow-rose-500/20'
]"
>
<TrendingUp v-if="transaction.type === 'income'" :size="20" :stroke-width="2.5" class="text-white" />
<TrendingDown v-else :size="20" :stroke-width="2.5" class="text-white" />
</div>
<div>
<p class="text-[11px] text-zinc-500 font-normal mb-0.5">Transaction details</p>
<h1 class="text-[17px] text-white font-semibold tracking-tight">{{ transaction.title }}</h1>
</div>
</div>
<button
@click="emit('close')"
class="w-10 h-10 rounded-[14px] bg-[#1A1A24] flex items-center justify-center hover:bg-[#222230] transition-colors border border-white/5"
>
<X :size="18" :stroke-width="2" class="text-zinc-400" />
</button>
</header>
<!-- Main Content -->
<main class="flex-1 overflow-y-auto px-5 pb-6">
<div class="space-y-4">
<!-- Transaction Summary Card -->
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 100 } }"
:class="[
'rounded-[20px] bg-gradient-to-br p-5 border relative overflow-hidden',
transaction.type === 'income'
? 'from-emerald-600/20 via-emerald-500/10 to-green-600/20 border-emerald-500/20'
: 'from-rose-600/20 via-rose-500/10 to-red-600/20 border-rose-500/20'
]"
>
<div
:class="[
'absolute -top-10 -right-10 w-32 h-32 rounded-full blur-3xl opacity-10',
transaction.type === 'income' ? 'bg-emerald-500' : 'bg-rose-500'
]"
/>
<div
:class="[
'absolute -bottom-8 -left-8 w-24 h-24 rounded-full blur-3xl opacity-10',
transaction.type === 'income' ? 'bg-green-500' : 'bg-red-500'
]"
/>
<div class="relative z-10">
<!-- Amount -->
<div class="text-center mb-5">
<p
:class="[
'text-[13px] font-medium mb-2',
transaction.type === 'income' ? 'text-emerald-300' : 'text-rose-300'
]"
>
{{ transaction.type === 'income' ? 'Income' : 'Expense' }}
</p>
<h2 class="text-white text-[42px] font-bold tracking-tight">
{{ transaction.type === 'income' ? '+' : '-' }}${{ transaction.amount.toFixed(2) }}
</h2>
</div>
<!-- Info Grid -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-white/5 backdrop-blur-sm rounded-[14px] p-3 border border-white/10">
<div class="flex items-center gap-2 mb-1">
<Calendar :size="14" :stroke-width="2" class="text-zinc-400" />
<p class="text-zinc-500 text-[11px] font-medium">Date</p>
</div>
<p class="text-white text-[13px] font-semibold">{{ transaction.date }}</p>
</div>
<div class="bg-white/5 backdrop-blur-sm rounded-[14px] p-3 border border-white/10">
<div class="flex items-center gap-2 mb-1">
<Clock :size="14" :stroke-width="2" class="text-zinc-400" />
<p class="text-zinc-500 text-[11px] font-medium">Time</p>
</div>
<p class="text-white text-[13px] font-semibold">{{ transaction.time }}</p>
</div>
<div class="bg-white/5 backdrop-blur-sm rounded-[14px] p-3 border border-white/10 col-span-2">
<div class="flex items-center gap-2 mb-1">
<Tag :size="14" :stroke-width="2" class="text-zinc-400" />
<p class="text-zinc-500 text-[11px] font-medium">Category</p>
</div>
<p class="text-white text-[13px] font-semibold">{{ transaction.category }}</p>
</div>
<div v-if="transaction.description" class="bg-white/5 backdrop-blur-sm rounded-[14px] p-3 border border-white/10 col-span-2">
<div class="flex items-center gap-2 mb-1">
<Receipt :size="14" :stroke-width="2" class="text-zinc-400" />
<p class="text-zinc-500 text-[11px] font-medium">Description</p>
</div>
<p class="text-white text-[13px]">{{ transaction.description }}</p>
</div>
</div>
</div>
</div>
<!-- Items List -->
<div
v-if="hasItems"
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 200 } }"
>
<div class="flex items-center gap-2 mb-3">
<ShoppingBag :size="16" :stroke-width="2" class="text-purple-400" />
<h2 class="text-white text-[15px] font-semibold">
Items ({{ transaction.items?.length }})
</h2>
</div>
<div class="space-y-2">
<div
v-for="(item, index) in transaction.items"
:key="item.id"
v-motion
:initial="{ opacity: 0, x: -10 }"
:enter="{ opacity: 1, x: 0, transition: { delay: 250 + index * 50 } }"
class="rounded-[16px] bg-[#16161F] border border-white/[0.06] p-4"
>
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<h3 class="text-white text-[14px] font-semibold mb-1">{{ item.name }}</h3>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<Hash :size="12" :stroke-width="2" class="text-zinc-600" />
<span class="text-zinc-500 text-[12px]">×{{ item.quantity }}</span>
</div>
<div class="flex items-center gap-1">
<span class="text-zinc-500 text-[12px]">${{ item.price.toFixed(2) }} each</span>
</div>
</div>
</div>
<div class="text-right">
<p class="text-white text-[15px] font-bold">
${{ (item.price * item.quantity).toFixed(2) }}
</p>
</div>
</div>
<div v-if="item.discount && item.discount > 0" class="pt-3 border-t border-white/[0.06]">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<Percent :size="14" :stroke-width="2" class="text-emerald-400" />
<span class="text-emerald-400 text-[12px] font-medium">Discount</span>
</div>
<span class="text-emerald-400 text-[12px] font-semibold">
-${{ (item.discount * item.quantity).toFixed(2) }}
</span>
</div>
</div>
</div>
</div>
<!-- Summary -->
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 300 + (transaction.items?.length || 0) * 50 } }"
class="mt-4 rounded-[16px] bg-[#16161F] border border-white/[0.06] p-4 space-y-2"
>
<div class="flex items-center justify-between">
<span class="text-zinc-500 text-[13px]">Subtotal</span>
<span class="text-white text-[13px] font-semibold">${{ subtotal.toFixed(2) }}</span>
</div>
<div v-if="totalDiscount > 0" class="flex items-center justify-between">
<span class="text-emerald-400 text-[13px]">Total Discount</span>
<span class="text-emerald-400 text-[13px] font-semibold">
-${{ totalDiscount.toFixed(2) }}
</span>
</div>
<div class="pt-2 border-t border-white/[0.06] flex items-center justify-between">
<span class="text-white text-[14px] font-semibold">Total</span>
<span class="text-white text-[16px] font-bold">
${{ (subtotal - totalDiscount).toFixed(2) }}
</span>
</div>
</div>
</div>
<!-- No Items Message -->
<div
v-else
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 200 } }"
class="rounded-[16px] bg-[#16161F] border border-white/[0.06] p-8 text-center"
>
<ShoppingBag :size="48" :stroke-width="1.5" class="text-zinc-700 mx-auto mb-3" />
<p class="text-zinc-500 text-[14px]">No items in this transaction</p>
</div>
</div>
</main>
<!-- Footer Actions -->
<div class="px-5 py-4 border-t border-white/[0.06]">
<button
@click="emit('close')"
class="w-full py-3.5 rounded-[14px] bg-gradient-to-br from-purple-500 to-blue-600 hover:shadow-lg hover:shadow-purple-500/30 text-white text-[14px] font-semibold transition-all active:scale-95"
>
Close
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
X,
Calendar,
Clock,
Tag,
Receipt,
ShoppingBag,
TrendingDown,
TrendingUp,
Percent,
Hash,
} from 'lucide-vue-next'
import type { Transaction } from '../types/transaction'
interface Props {
transaction: Transaction
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
}>()
const hasItems = computed(() => props.transaction.items && props.transaction.items.length > 0)
const subtotal = computed(() =>
props.transaction.items?.reduce((sum, item) => sum + (item.price * item.quantity), 0) || 0
)
const totalDiscount = computed(() =>
props.transaction.items?.reduce((sum, item) => sum + ((item.discount || 0) * item.quantity), 0) || 0
)
</script>
+70
View File
@@ -113,11 +113,43 @@ const messages: Record<Locale, Messages> = {
'finance.categories.entertainment': 'Entertainment',
'finance.categories.coffee': 'Coffee',
'finance.categories.billsUtilities': 'Bills & Utilities',
'finance.categories.healthcare': 'Healthcare',
'finance.categories.work': 'Work',
'finance.categories.giftsDonations': 'Gifts & Donations',
'finance.categories.others': 'Others',
'finance.categories.of': 'of',
'finance.add.title': 'Transaction',
'finance.add.eyebrow': 'Add new',
'finance.add.tab.manual': 'Manual',
'finance.add.tab.receipt': 'Receipt',
'finance.add.tab.photo': 'Photo',
'finance.add.type': 'Type',
'finance.add.type.expense': 'Expense',
'finance.add.type.income': 'Income',
'finance.add.amount': 'Amount',
'finance.add.category': 'Category',
'finance.add.category.select': 'Select category',
'finance.add.datetime': 'Date & Time',
'finance.add.description': 'Description',
'finance.add.description.placeholder': 'Add notes...',
'finance.add.optional': '(optional)',
'finance.add.receiptNumber': 'Receipt Number',
'finance.add.receiptNumber.placeholder': 'Enter receipt number',
'finance.add.receiptDate': 'Receipt Date',
'finance.add.photo.label': 'Receipt Photo',
'finance.add.photo.upload': 'Upload receipt photo',
'finance.add.photo.select': 'Tap to select from gallery',
'finance.add.photo.change': 'Tap to change',
'finance.add.submit': 'Add Transaction',
'finance.add.submitting': 'Processing...',
'finance.add.cancel': 'Cancel',
'finance.add.error.missingFields': 'Please fill all required fields',
'finance.add.error.missingPhoto': 'Please select a photo',
'finance.add.error.missingFamily': 'Family ID is missing',
'finance.add.error.missingUser': 'User ID is missing',
'finance.add.error.generic': 'Failed to add transaction',
'settings.header.eyebrow': 'Manage your family hub',
'settings.header.title': 'Settings',
'settings.profile.role': 'Family Admin',
@@ -152,6 +184,9 @@ const messages: Record<Locale, Messages> = {
'settings.module.active': 'Active',
'settings.module.disabled': 'Disabled',
'settings.signOut': 'Sign Out',
'calendar.header.eyebrow': 'Plan your week',
'calendar.header.title': 'Calendar',
},
ru: {
'language.english': 'Английский',
@@ -259,11 +294,43 @@ const messages: Record<Locale, Messages> = {
'finance.categories.entertainment': 'Развлечения',
'finance.categories.coffee': 'Кофе',
'finance.categories.billsUtilities': 'Счета и коммунальные',
'finance.categories.healthcare': 'Здоровье',
'finance.categories.work': 'Работа',
'finance.categories.giftsDonations': 'Подарки и пожертвования',
'finance.categories.others': 'Прочее',
'finance.categories.of': 'из',
'finance.add.title': 'Транзакция',
'finance.add.eyebrow': 'Добавить',
'finance.add.tab.manual': 'Вручную',
'finance.add.tab.receipt': 'По чеку',
'finance.add.tab.photo': 'Фото',
'finance.add.type': 'Тип',
'finance.add.type.expense': 'Расход',
'finance.add.type.income': 'Доход',
'finance.add.amount': 'Сумма',
'finance.add.category': 'Категория',
'finance.add.category.select': 'Выберите категорию',
'finance.add.datetime': 'Дата и время',
'finance.add.description': 'Описание',
'finance.add.description.placeholder': 'Добавьте примечания...',
'finance.add.optional': '(опционально)',
'finance.add.receiptNumber': 'Номер чека',
'finance.add.receiptNumber.placeholder': 'Введите номер чека',
'finance.add.receiptDate': 'Дата чека',
'finance.add.photo.label': 'Фото чека',
'finance.add.photo.upload': 'Загрузите фото чека',
'finance.add.photo.select': 'Нажмите, чтобы выбрать из галереи',
'finance.add.photo.change': 'Нажмите, чтобы изменить',
'finance.add.submit': 'Добавить транзакцию',
'finance.add.submitting': 'Обработка...',
'finance.add.cancel': 'Отмена',
'finance.add.error.missingFields': 'Пожалуйста, заполните все обязательные поля',
'finance.add.error.missingPhoto': 'Пожалуйста, выберите фото',
'finance.add.error.missingFamily': 'ID семьи отсутствует',
'finance.add.error.missingUser': 'ID пользователя отсутствует',
'finance.add.error.generic': 'Не удалось добавить транзакцию',
'settings.header.eyebrow': 'Управляйте семейным хабом',
'settings.header.title': 'Настройки',
'settings.profile.role': 'Администратор семьи',
@@ -298,6 +365,9 @@ const messages: Record<Locale, Messages> = {
'settings.module.active': 'Активен',
'settings.module.disabled': 'Отключён',
'settings.signOut': 'Выйти',
'calendar.header.eyebrow': 'Планируй свою неделю',
'calendar.header.title': 'Календарь',
},
}
+27
View File
@@ -0,0 +1,27 @@
export interface TransactionItem {
id: number
name: string
price: number
quantity: number
discount?: number
}
export interface Transaction {
id: string
title: string
category: string
amount: number
type: 'income' | 'expense'
icon: any
color: string
time: string
date: string
description?: string
items?: TransactionItem[]
}
export interface TransactionGroup {
date: string
total: number
transactions: Transaction[]
}
+1
View File
@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_FAMILY_ID?: string
readonly VITE_USER_ID?: string
}
interface ImportMeta {
+2 -1
View File
@@ -39,7 +39,8 @@ FROM scratch
COPY --from=backend /app/server /server
COPY --from=backend /app/migrations /migrations
COPY --from=backend /app/src/api/dist /src/api/dist
COPY --from=backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=backend /usr/share/zoneinfo /usr/share/zoneinfo
ENTRYPOINT ["/server"]
ENTRYPOINT ["/server"]