15 Commits

Author SHA1 Message Date
admin a9c931bd71 fix integration
Build and Deploy / build-and-deploy (push) Successful in 19m19s
2026-06-04 19:52:44 +03:00
admin 5f5c5de407 fix action
Build and Deploy / build-and-deploy (push) Successful in 1m30s
2026-06-04 17:41:02 +03:00
admin 2cc1245d12 Merge pull request '12 Сделать добавление транзакций на фронте, добавить уже сгенерированые экраны в проект' (#14) from 12-Add-transactions into main
Build and Deploy / build-and-deploy (push) Failing after 37s
Reviewed-on: #14
2026-06-04 15:19:26 +03:00
admin 93506a2038 12 Сделать добавление транзакций на фронте, добавить уже сгенерированые экраны в проект 2026-06-04 15:17:56 +03:00
admin 64fef9f674 Merge pull request '12 Сделать добавление транзакций на фронте, добавить уже сгенерированые экраны в проект' (#13) from 12-Add-transactions into main
Build and Deploy / build-and-deploy (push) Successful in 16m50s
Reviewed-on: #13
2026-06-04 13:45:14 +03:00
admin 97d923142e 12 Сделать добавление транзакций на фронте, добавить уже сгенерированые экраны в проект 2026-06-04 13:44:03 +03:00
admin debb8e5974 Fixed pipeline
Build and Deploy / build-and-deploy (push) Successful in 1m22s
2026-05-27 00:07:44 +03:00
admin d1b95a9312 Merge pull request 'Added possibility deploy with k3s' (#11) from 8-Add-Pipelines-for-autodeploy into main
Build and Deploy / build-and-deploy (push) Failing after 4m36s
Reviewed-on: #11
2026-05-26 23:03:44 +03:00
admin 39425af43e Added possibility deploy with k3s 2026-05-26 23:02:11 +03:00
admin e6096c98fa Merge pull request 'Made autodeploy pipeline' (#9) from 8-Add-Pipelines-for-autodeploy into main
Build and Deploy / build-and-deploy (push) Has been cancelled
Reviewed-on: #9
2026-05-19 22:02:05 +03:00
admin b6447cce63 Made autodeploy pipeline 2026-05-19 22:01:20 +03:00
admin b17b43b17a Merge pull request '2 Сделать отображение транзакций на фронте' (#7) from 2-made-transactions-UI into main
Reviewed-on: #7
2026-05-17 20:23:02 +03:00
admin baef5a0af2 2 Сделать отображение транзакций на фронте 2026-05-17 18:36:14 +03:00
admin a4f9bb63aa Merge pull request 'Added structured logging across services and repositories. Updated SQL queries to use parameterized placeholders for better readability and security. Enhanced error handling for external service communication.' (#3) from 1-Fix-transactions into main
Reviewed-on: #3
2026-05-15 23:40:14 +03:00
admin 8462b16305 Added structured logging across services and repositories. Updated SQL queries to use parameterized placeholders for better readability and security. Enhanced error handling for external service communication. 2026-05-15 22:07:03 +03:00
67 changed files with 3358 additions and 382 deletions
+79
View File
@@ -0,0 +1,79 @@
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=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/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=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: |
ARCH=$(uname -m)
case $ARCH in
x86_64) KUBECTL_ARCH="amd64" ;;
aarch64) KUBECTL_ARCH="arm64" ;;
armv7l) KUBECTL_ARCH="arm" ;;
esac
VERSION=$(curl --http1.1 -Ls https://dl.k8s.io/release/stable.txt)
curl --http1.1 -LO \
"https://dl.k8s.io/release/${VERSION}/bin/linux/${KUBECTL_ARCH}/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
View File
@@ -6,4 +6,6 @@ data
archive archive
volumes volumes
*.dtmp *.dtmp
*.gocache *.gocache
infra/k8s/secrets.yaml
infra/k8s/google-creds.yaml
@@ -0,0 +1 @@
DROP EXTENSION pg_cron;
+41 -21
View File
@@ -5,9 +5,11 @@ import (
"FamilyHub/src/api/requests" "FamilyHub/src/api/requests"
"FamilyHub/src/api/services" "FamilyHub/src/api/services"
receiptServiceIntegration "FamilyHub/src/integrations/receiptProvider" receiptServiceIntegration "FamilyHub/src/integrations/receiptProvider"
"context"
"database/sql" "database/sql"
"errors" "errors"
"log" "log"
"net"
"net/http" "net/http"
"runtime/debug" "runtime/debug"
@@ -38,27 +40,7 @@ func logInternalError(c *gin.Context, scope string, err error) {
} }
func handleReceiptError(c *gin.Context, err error) { func handleReceiptError(c *gin.Context, err error) {
var externalErr *receiptServiceIntegration.ExternalServiceError if !handleReceiptProviderError(c, err) {
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:
logInternalError(c, "receipt request", err) logInternalError(c, "receipt request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) 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"}) 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()
}
+15 -4
View File
@@ -7,6 +7,7 @@ import (
"FamilyHub/src/domain" "FamilyHub/src/domain"
"errors" "errors"
"io" "io"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -64,28 +65,31 @@ func (router *TransactionsRouter) Create(c *gin.Context) {
var req dto.CreateTransactionRequest var req dto.CreateTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
input, err := requests.BuildCreateTransactionInput(req) input, err := requests.BuildCreateTransactionInput(req)
if err != nil { if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
log.Printf("%+v\n", input)
transaction, err := router.creationService.Create(c.Request.Context(), input) transaction, err := router.creationService.Create(c.Request.Context(), input)
if err != nil { if err != nil {
handleTransactionError(c, err) handleTransactionError(c, err)
return return
} }
log.Printf("%+v\n", transaction)
c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction)) c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction))
} }
func (router *TransactionsRouter) createFromMultipart(c *gin.Context) { func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
fileHeader, err := c.FormFile("photo") fileHeader, err := c.FormFile("photo")
if err != nil { if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"})
return return
} }
@@ -107,11 +111,13 @@ func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
familyID, err := parseOptionalInt64Form(c, "family_id") familyID, err := parseOptionalInt64Form(c, "family_id")
if err != nil { if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
createdBy, err := parseOptionalInt64Form(c, "created_by") createdBy, err := parseOptionalInt64Form(c, "created_by")
if err != nil { if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
@@ -125,6 +131,7 @@ func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
Description: parseOptionalStringForm(c, "description"), Description: parseOptionalStringForm(c, "description"),
}) })
if err != nil { if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
@@ -334,9 +341,15 @@ func (router *TransactionsRouter) Delete(c *gin.Context) {
} }
func handleTransactionError(c *gin.Context, err error) { func handleTransactionError(c *gin.Context, err error) {
if handleReceiptProviderError(c, err) {
return
}
switch { switch {
case errors.Is(err, services.ErrTransactionNotFound): case errors.Is(err, services.ErrTransactionNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptAlreadyExists):
c.JSON(http.StatusConflict, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrTransactionPatch), case errors.Is(err, services.ErrTransactionPatch),
errors.Is(err, services.ErrReceiptLinkConflict), errors.Is(err, services.ErrReceiptLinkConflict),
errors.Is(err, services.ErrInvalidTransaction), errors.Is(err, services.ErrInvalidTransaction),
@@ -351,8 +364,6 @@ func handleTransactionError(c *gin.Context, err error) {
errors.Is(err, services.ErrOCRNotConfigured), errors.Is(err, services.ErrOCRNotConfigured),
errors.Is(err, services.ErrReceiptTransactionNotCreated): errors.Is(err, services.ErrReceiptTransactionNotCreated):
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: err.Error()}) 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: default:
logInternalError(c, "transaction request", err) logInternalError(c, "transaction request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
@@ -5,6 +5,7 @@ import (
"FamilyHub/src/api/requests" "FamilyHub/src/api/requests"
"FamilyHub/src/api/services" "FamilyHub/src/api/services"
"FamilyHub/src/domain" "FamilyHub/src/domain"
receiptProvider "FamilyHub/src/integrations/receiptProvider"
"bytes" "bytes"
"context" "context"
"errors" "errors"
@@ -199,6 +200,54 @@ func TestTransactionsRouter_Create(t *testing.T) {
assert.Contains(t, w.Body.String(), `"id":21`) 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) { t.Run("creates transaction from photo upload", func(t *testing.T) {
r := gin.New() r := gin.New()
apiV1 := r.Group("/api/v1") apiV1 := r.Group("/api/v1")
@@ -290,6 +339,110 @@ func TestTransactionsRouter_Create(t *testing.T) {
require.Equal(t, http.StatusBadRequest, w.Code) require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "family_id and created_by are required for receipt transaction") 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 409 when receipt already exists 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, services.ErrReceiptAlreadyExists
}}
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.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "receipt already exists")
})
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 { func ptrInt64(v int64) *int64 {
+2
View File
@@ -128,6 +128,8 @@ func NewServer(cfg config.Config) *Server {
authRouter := routers.NewAuthRouter(authService) authRouter := routers.NewAuthRouter(authService)
authRouter.RegisterRouter(apiV1) authRouter.RegisterRouter(apiV1)
// подключаем статику Vue — должно быть последним
registerStaticFiles(router, "src/api/dist")
return &Server{ return &Server{
httpServer: &http.Server{ httpServer: &http.Server{
Addr: cfg.APIHost + ":" + cfg.APIPort, Addr: cfg.APIHost + ":" + cfg.APIPort,
+18 -2
View File
@@ -4,8 +4,11 @@ import (
"FamilyHub/src/domain" "FamilyHub/src/domain"
"FamilyHub/src/integrations/receiptProvider" "FamilyHub/src/integrations/receiptProvider"
"FamilyHub/src/repositories" "FamilyHub/src/repositories"
"FamilyHub/src/utils"
"context" "context"
"errors"
"fmt" "fmt"
"log"
"strings" "strings"
) )
@@ -35,18 +38,27 @@ func (s *receiptService) AddReceipt(
ctx context.Context, ctx context.Context,
req domain.AddReceiptRequest, req domain.AddReceiptRequest,
) (*domain.Receipt, error) { ) (*domain.Receipt, error) {
log.Printf("receipt add request: payload=%s", utils.ToLogJSON(req))
receipt, err := s.provider.GetReceipt(ctx, req.Date, req.Number) receipt, err := s.provider.GetReceipt(ctx, req.Date, req.Number)
if err != nil { if err != nil {
log.Printf("receipt add failed: err=%v payload=%s", err, utils.ToLogJSON(req))
return nil, err return nil, err
} }
receiptID, err := s.repo.Create(ctx, receipt) receiptID, err := s.repo.Create(ctx, receipt)
if err != nil { if err != nil {
if errors.Is(err, repositories.ErrReceiptAlreadyExists) {
log.Printf("receipt persist failed: err=%v receipt=%s", ErrReceiptAlreadyExists, utils.ToLogJSON(receipt))
return nil, ErrReceiptAlreadyExists
}
log.Printf("receipt persist failed: err=%v receipt=%s", err, utils.ToLogJSON(receipt))
return nil, err return nil, err
} }
receipt.ID = int(receiptID) receipt.ID = int(receiptID)
if !s.shouldCreateTransaction(req) { if !s.shouldCreateTransaction(req) {
log.Printf("receipt add response: payload=%s", utils.ToLogJSON(receipt))
return receipt, nil return receipt, nil
} }
@@ -59,6 +71,7 @@ func (s *receiptService) AddReceipt(
} }
receipt.TransactionID = &transaction.ID receipt.TransactionID = &transaction.ID
log.Printf("receipt add response: payload=%s", utils.ToLogJSON(receipt))
return receipt, nil return receipt, nil
} }
@@ -94,11 +107,14 @@ func (s *receiptService) createTransactionForReceipt(
CreatedBy: *req.CreatedBy, CreatedBy: *req.CreatedBy,
ReceiptID: &receiptID, ReceiptID: &receiptID,
} }
log.Printf("%+v\n", transaction)
log.Printf("receipt transaction create request: payload=%s", utils.ToLogJSON(transaction))
if err := s.transactionRepo.Create(ctx, transaction); err != nil { if err := s.transactionRepo.Create(ctx, transaction); err != nil {
log.Printf("receipt transaction create failed: err=%v payload=%s", err, utils.ToLogJSON(transaction))
return nil, err return nil, err
} }
log.Printf("receipt transaction create response: payload=%s", utils.ToLogJSON(transaction))
return transaction, nil return transaction, nil
} }
@@ -108,7 +124,7 @@ func buildReceiptTransactionDescription(receipt *domain.Receipt, explicit *strin
return &value return &value
} }
if name := strings.TrimSpace(receipt.NameSPD); name != "" { if name := strings.TrimSpace(receipt.NameTO); name != "" {
return &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)
}
}
+15 -6
View File
@@ -3,10 +3,12 @@ package services
import ( import (
"FamilyHub/src/domain" "FamilyHub/src/domain"
"FamilyHub/src/repositories" "FamilyHub/src/repositories"
"FamilyHub/src/utils"
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"log"
"strings" "strings"
) )
@@ -29,16 +31,20 @@ func NewTransactionService(repo repositories.TransactionRepository, activityRepo
} }
var ( var (
ErrTransactionNotFound = errors.New("transaction not found") ErrTransactionNotFound = errors.New("transaction not found")
ErrTransactionPatch = errors.New("empty update payload") ErrTransactionPatch = errors.New("empty update payload")
ErrReceiptLinkConflict = errors.New("receipt_id and detach_receipt cannot be used together") ErrReceiptLinkConflict = errors.New("receipt_id and detach_receipt cannot be used together")
ErrInvalidTransaction = errors.New("type and category are required") ErrReceiptAlreadyExists = errors.New("receipt already exists")
ErrReceiptNotFound = errors.New("receipt not found") ErrInvalidTransaction = errors.New("type and category are required")
ErrInvalidAnalytics = errors.New("type must be income or expense") ErrReceiptNotFound = errors.New("receipt not found")
ErrInvalidAnalytics = errors.New("type must be income or expense")
) )
func (s *transactionService) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) { func (s *transactionService) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) {
log.Printf("transaction create request: payload=%s", utils.ToLogJSON(req))
if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Category) == "" { if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Category) == "" {
log.Printf("transaction create failed: err=%v payload=%s", ErrInvalidTransaction, utils.ToLogJSON(req))
return nil, ErrInvalidTransaction return nil, ErrInvalidTransaction
} }
@@ -55,8 +61,10 @@ func (s *transactionService) Create(ctx context.Context, req domain.CreateTransa
if err := s.repo.Create(ctx, transaction); err != nil { if err := s.repo.Create(ctx, transaction); err != nil {
if errors.Is(err, repositories.ErrReceiptNotFound) { if errors.Is(err, repositories.ErrReceiptNotFound) {
log.Printf("transaction create failed: err=%v payload=%s", ErrReceiptNotFound, utils.ToLogJSON(req))
return nil, ErrReceiptNotFound return nil, ErrReceiptNotFound
} }
log.Printf("transaction create failed: err=%v payload=%s", err, utils.ToLogJSON(req))
return nil, err return nil, err
} }
@@ -72,6 +80,7 @@ func (s *transactionService) Create(ctx context.Context, req domain.CreateTransa
}) })
} }
log.Printf("transaction create response: payload=%s", utils.ToLogJSON(transaction))
return transaction, nil return transaction, nil
} }
+27
View File
@@ -0,0 +1,27 @@
package api
import (
"net/http"
"os"
"github.com/gin-gonic/gin"
)
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
}
panic(err)
}
fileServer := http.FileServer(http.Dir(staticDir))
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())
}
+29 -4
View File
@@ -2,6 +2,7 @@ package config
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"strings" "strings"
@@ -33,7 +34,6 @@ func Load() (Config, error) {
mode := os.Getenv("RUN_MODE") mode := os.Getenv("RUN_MODE")
debugMode := os.Getenv("DEBUG_MODE") == "true" debugMode := os.Getenv("DEBUG_MODE") == "true"
botToken := os.Getenv("BOT_TOKEN") botToken := os.Getenv("BOT_TOKEN")
dbConnectionString := os.Getenv("DB_PATH")
ocrTokenPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") ocrTokenPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
apiPort := os.Getenv("API_PORT") apiPort := os.Getenv("API_PORT")
apiHost := os.Getenv("API_HOST") apiHost := os.Getenv("API_HOST")
@@ -42,6 +42,7 @@ func Load() (Config, error) {
openAPIEndpoint := os.Getenv("OPEN_API_ENDPOINT") openAPIEndpoint := os.Getenv("OPEN_API_ENDPOINT")
runMode, err := ParseRunMode(mode) runMode, err := ParseRunMode(mode)
dbConnectionString := buildConnectionString()
if err != nil { if err != nil {
warnings = append(warnings, err.Error()) warnings = append(warnings, err.Error())
} }
@@ -61,9 +62,6 @@ func Load() (Config, error) {
if apiSecret == "" { if apiSecret == "" {
warnings = append(warnings, "Missing required environment variable: API_SECRET") warnings = append(warnings, "Missing required environment variable: API_SECRET")
} }
if dbConnectionString == "" {
dbConnectionString = "sqlite://data/app.db"
}
if apiHost == "" { if apiHost == "" {
apiHost = "localhost" apiHost = "localhost"
} }
@@ -92,3 +90,30 @@ func Load() (Config, error) {
TelegramApi: "https://api.telegram.org", TelegramApi: "https://api.telegram.org",
}, nil }, 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,
)
}
+51 -18
View File
@@ -3,11 +3,14 @@ package familyHub
import ( import (
"FamilyHub/src/config" "FamilyHub/src/config"
"FamilyHub/src/domain" "FamilyHub/src/domain"
"FamilyHub/src/utils"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -63,14 +66,13 @@ func (c *HTTPClient) SendReceipt(ctx context.Context, payload domain.AddReceiptR
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req) responseBody, statusCode, err := c.doRequest(req, "familyhub_api.transactions.create", body)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
if resp.StatusCode >= 300 { if statusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode) return fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
} }
return nil return nil
@@ -120,14 +122,13 @@ func (c *HTTPClient) RegisterUser(ctx context.Context, payload domain.CreateUser
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req) responseBody, statusCode, err := c.doRequest(req, "familyhub_api.users.create", body)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
if resp.StatusCode >= 300 { if statusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode) return fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
} }
return nil return nil
@@ -144,22 +145,21 @@ func (c *HTTPClient) GetUserByTelegramID(ctx context.Context, telegramID int64)
return nil, err return nil, err
} }
resp, err := c.client.Do(req) responseBody, statusCode, err := c.doRequest(req, "familyhub_api.users.by_telegram", nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound { if statusCode == http.StatusNotFound {
return nil, errUserNotFound return nil, errUserNotFound
} }
if resp.StatusCode >= 300 { if statusCode >= 300 {
return nil, fmt.Errorf("api error: status %d", resp.StatusCode) return nil, fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
} }
var user domain.UserResponse var user domain.UserResponse
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { if err := json.Unmarshal(responseBody, &user); err != nil {
return nil, err return nil, err
} }
@@ -184,15 +184,48 @@ func (c *HTTPClient) CreateFamily(ctx context.Context, payload domain.CreateFami
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req) responseBody, statusCode, err := c.doRequest(req, "familyhub_api.families.create", body)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
if resp.StatusCode >= 300 { if statusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode) return fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
} }
return nil return nil
} }
func (c *HTTPClient) doRequest(req *http.Request, service string, requestBody []byte) ([]byte, int, error) {
log.Printf(
"external request: service=%s method=%s url=%s body=%q",
service,
req.Method,
req.URL.String(),
utils.TruncateForLog(string(requestBody), utils.DefaultLogValueLimit),
)
resp, err := c.client.Do(req)
if err != nil {
log.Printf("external response: service=%s method=%s url=%s err=%v", service, req.Method, req.URL.String(), err)
return nil, 0, err
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
log.Printf("external response: service=%s method=%s url=%s status=%d read_err=%v", service, req.Method, req.URL.String(), resp.StatusCode, readErr)
return nil, resp.StatusCode, readErr
}
log.Printf(
"external response: service=%s method=%s url=%s status=%d body=%q",
service,
req.Method,
req.URL.String(),
resp.StatusCode,
utils.TruncateForLog(string(responseBody), utils.DefaultLogValueLimit),
)
return responseBody, resp.StatusCode, nil
}
@@ -2,9 +2,14 @@ package familyHub
import ( import (
"FamilyHub/src/config" "FamilyHub/src/config"
"FamilyHub/src/utils"
"context" "context"
"fmt"
"io"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
) )
@@ -18,19 +23,42 @@ func NewBotClient(config config.Config) (*HTTPClient, error) {
} }
func (c *HTTPClient) SendMessage(ctx context.Context, chatId int64, message string) error { func (c *HTTPClient) SendMessage(ctx context.Context, chatId int64, message string) error {
url := c.config.TelegramApi + "/bot" + c.config.BotToken + "/sendMessage?chat_id=" + strconv.FormatInt(chatId, 10) + "&text=" + message
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodGet, http.MethodGet,
c.config.TelegramApi+"/bot"+c.config.BotToken+"/sendMessage?chat_id="+strconv.FormatInt(chatId, 10)+"&text="+message, url,
nil, nil,
) )
if err != nil { if err != nil {
return err return err
} }
logURL := strings.ReplaceAll(req.URL.String(), c.config.BotToken, "***")
log.Printf(
"external request: service=telegram_bot.send_message method=%s url=%s body=%q",
http.MethodGet,
logURL,
utils.TruncateForLog(fmt.Sprintf("chat_id=%d&text=%s", chatId, message), utils.DefaultLogValueLimit),
)
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
log.Printf("external response: service=telegram_bot.send_message method=%s url=%s err=%v", http.MethodGet, logURL, err)
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
log.Printf("external response: service=telegram_bot.send_message method=%s url=%s status=%d read_err=%v", http.MethodGet, logURL, resp.StatusCode, readErr)
return readErr
}
log.Printf(
"external response: service=telegram_bot.send_message method=%s url=%s status=%d body=%q",
http.MethodGet,
logURL,
resp.StatusCode,
utils.TruncateForLog(string(responseBody), utils.DefaultLogValueLimit),
)
return nil return nil
} }
+12
View File
@@ -1,9 +1,11 @@
package ocr package ocr
import ( import (
"FamilyHub/src/utils"
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"log"
vision "cloud.google.com/go/vision/apiv1" vision "cloud.google.com/go/vision/apiv1"
) )
@@ -30,19 +32,29 @@ func (g *GoogleOCR) Close() error {
} }
func (g *GoogleOCR) Recognize(ctx context.Context, image []byte) (string, error) { func (g *GoogleOCR) Recognize(ctx context.Context, image []byte) (string, error) {
log.Printf("external request: service=google_ocr.detect_text image_size_bytes=%d", len(image))
img, err := vision.NewImageFromReader(bytes.NewReader(image)) img, err := vision.NewImageFromReader(bytes.NewReader(image))
if err != nil { if err != nil {
log.Printf("external response: service=google_ocr.detect_text err=%v", err)
return "", fmt.Errorf("load image: %w", err) return "", fmt.Errorf("load image: %w", err)
} }
annotations, err := g.client.DetectTexts(ctx, img, nil, 1) annotations, err := g.client.DetectTexts(ctx, img, nil, 1)
if err != nil { if err != nil {
log.Printf("external response: service=google_ocr.detect_text err=%v", err)
return "", fmt.Errorf("detect text: %w", err) return "", fmt.Errorf("detect text: %w", err)
} }
if len(annotations) == 0 { if len(annotations) == 0 {
log.Printf("external response: service=google_ocr.detect_text result=%q", "")
return "", nil return "", nil
} }
log.Printf(
"external response: service=google_ocr.detect_text result=%q annotations=%d",
utils.TruncateForLog(annotations[0].Description, utils.DefaultLogValueLimit),
len(annotations),
)
return annotations[0].Description, nil return annotations[0].Description, nil
} }
@@ -13,6 +13,8 @@ import (
"log" "log"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/cookiejar"
"regexp"
"strings" "strings"
"time" "time"
) )
@@ -44,8 +46,10 @@ type receiptProvider struct {
} }
func NewReceiptProvider() *receiptProvider { func NewReceiptProvider() *receiptProvider {
jar, _ := cookiejar.New(nil)
return &receiptProvider{ return &receiptProvider{
client: &http.Client{ client: &http.Client{
Jar: jar,
Timeout: 60 * time.Second, Timeout: 60 * time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
@@ -56,57 +60,113 @@ func NewReceiptProvider() *receiptProvider {
} }
} }
// ===== SESSID EXTRACT =====
var sessidRe = regexp.MustCompile(`"bitrix_sessid"\s*:\s*"([a-f0-9]+)"`)
func extractSessID(html string) (string, error) {
matches := sessidRe.FindStringSubmatch(html)
if len(matches) < 2 {
return "", errors.New("bitrix_sessid not found")
}
return matches[1], nil
}
// ===== MAIN =====
func (s *receiptProvider) GetReceipt( func (s *receiptProvider) GetReceipt(
ctx context.Context, ctx context.Context,
date, number string, date, number string,
) (*domain.Receipt, error) { ) (*domain.Receipt, error) {
url := "https://ch.info-center.by/ajax/check1.php"
var receipt domain.Receipt
body, contentType := buildMultipartBody(date, number) portalURL := "https://ch.info-center.by/"
// ===== 1. GET PAGE =====
getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, portalURL, nil)
if err != nil {
return nil, err
}
getReq.Header.Set("User-Agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36")
resp, err := s.client.Do(getReq)
if err != nil {
return nil, err
}
htmlBytes, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
html := string(htmlBytes)
// ===== 2. EXTRACT SESSID =====
sessid, err := extractSessID(html)
if err != nil {
log.Printf("sessid extract error: %v", err)
return nil, err
}
log.Printf("sessid: %s", sessid)
// ===== 3. POST REQUEST =====
time.Sleep(300 * time.Millisecond)
postUrl := "https://ch.info-center.by/ajax/check1.php"
body, contentType := buildMultipartBody(date, number, sessid)
httpReq, err := http.NewRequestWithContext( httpReq, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
url, postUrl,
body, body,
) )
if err != nil { if err != nil {
log.Println(err.Error())
return nil, err return nil, err
} }
httpReq.Header.Set("Content-Type", contentType) httpReq.Header.Set("Content-Type", contentType)
httpReq.Header.Set("Accept", "*/*")
httpReq.Header.Set("X-Requested-With", "XMLHttpRequest")
httpReq.Header.Set("Origin", "https://ch.info-center.by")
httpReq.Header.Set("Referer", "https://ch.info-center.by/")
resp, err := s.client.Do(httpReq) resp, err = s.client.Do(httpReq)
if err != nil { if err != nil {
log.Println(err.Error())
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
bodyText := strings.TrimSpace(string(responseBody))
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if readErr != nil {
log.Printf("failed to read external service error body: %v", readErr)
}
bodyText := strings.TrimSpace(string(responseBody))
log.Printf("external service returned %d body=%q", resp.StatusCode, bodyText)
return nil, &ExternalServiceError{ return nil, &ExternalServiceError{
StatusCode: resp.StatusCode, StatusCode: resp.StatusCode,
Body: bodyText, Body: bodyText,
} }
} }
// ===== PARSE =====
var raw struct { var raw struct {
Message map[string]interface{} `json:"message"` Message map[string]interface{} `json:"message"`
} }
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
log.Printf("external service returned %s\n", err.Error()) if err := json.Unmarshal(responseBody, &raw); err != nil {
return nil, err return nil, err
} }
bytes_, _ := json.Marshal(raw.Message) bytes_, _ := json.Marshal(raw.Message)
var receipt domain.Receipt
if err := json.Unmarshal(bytes_, &receipt); err != nil { if err := json.Unmarshal(bytes_, &receipt); err != nil {
return nil, err return nil, err
} }
@@ -117,12 +177,11 @@ func (s *receiptProvider) GetReceipt(
positions, err := parsePositions(receipt.PositionsRaw) positions, err := parsePositions(receipt.PositionsRaw)
if err != nil { if err != nil {
log.Printf("failed to parse positions: %s", err.Error())
return nil, err return nil, err
} }
receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw) receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw)
if err != nil { if err != nil {
log.Printf("failed to parse issued at: %s", err.Error())
return nil, err return nil, err
} }
@@ -133,13 +192,11 @@ func (s *receiptProvider) GetReceipt(
p.ProductCount, err = utils.ParseFloat(p.ProductCountRaw) p.ProductCount, err = utils.ParseFloat(p.ProductCountRaw)
if err != nil { if err != nil {
log.Printf("failed to parse product count: %s", err.Error())
return nil, err return nil, err
} }
p.Amount, err = utils.ParseFloat(p.AmountRaw) p.Amount, err = utils.ParseFloat(p.AmountRaw)
if err != nil { if err != nil {
log.Printf("failed to parse amount: %s", err.Error())
return nil, err return nil, err
} }
@@ -150,13 +207,23 @@ func (s *receiptProvider) GetReceipt(
return &receipt, nil return &receipt, nil
} }
func buildMultipartBody(date, number string) (*bytes.Buffer, string) { // ===== MULTIPART =====
func buildMultipartBody(date, number, sessid string) (*bytes.Buffer, string) {
body := &bytes.Buffer{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) 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.WriteField("orig_ui", number)
// 🔥 ВОТ ГЛАВНОЕ
_ = writer.WriteField("sessid", sessid)
_ = writer.Close() _ = writer.Close()
return body, writer.FormDataContentType() return body, writer.FormDataContentType()
@@ -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)
}
}
+106 -32
View File
@@ -4,10 +4,14 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"log"
"FamilyHub/src/domain" "FamilyHub/src/domain"
"github.com/lib/pq"
) )
var ErrReceiptAlreadyExists = errors.New("receipt already exists")
type ReceiptsRepository interface { type ReceiptsRepository interface {
Create(ctx context.Context, receipt *domain.Receipt) (int64, error) Create(ctx context.Context, receipt *domain.Receipt) (int64, error)
GetByID(ctx context.Context, id int64) (*domain.Receipt, error) GetByID(ctx context.Context, id int64) (*domain.Receipt, error)
@@ -25,29 +29,63 @@ func NewReceiptsSQLRepository(db *sql.DB) *ReceiptsSQLRepository {
} }
func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Receipt) (int64, error) { func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Receipt) (int64, error) {
log.Printf("%+v\n", receipt)
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer tx.Rollback() defer tx.Rollback()
if receipt.ReceiptNumber != receipt.UI { if receipt.ReceiptNumber != receipt.UI {
receipt.ReceiptNumber = receipt.UI receipt.ReceiptNumber = receipt.UI
} }
res, err := tx.ExecContext(ctx, `
log.Println("First query")
query := `
INSERT INTO receipts ( INSERT INTO receipts (
transaction_id, receipt_number, ui, status, issued_at, transaction_id,
total_amount, payment_amount, cash_amount, receipt_number,
another_amount, clearing_amount, margin, ui,
currency, payment_type, status,
cashbox_number, cashier, issued_at,
name_spd, name_to, name_np, type_np, total_amount,
street_to, house_to, payment_amount,
kod_soato, oblast_soato, rayon_soato, selsovet_soato, cash_amount,
doc_num, skno_number, unp, another_amount,
clearing_amount,
margin,
currency,
payment_type,
cashbox_number,
cashier,
name_spd,
name_to,
name_np,
type_np,
street_to,
house_to,
kod_soato,
oblast_soato,
rayon_soato,
selsovet_soato,
doc_num,
skno_number,
unp,
success success
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) )
`, VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12, $13, $14, $15,
$16, $17, $18, $19, $20,
$21, $22, $23, $24, $25,
$26, $27, $28, $29
)
RETURNING id;
`
args := []any{
receipt.TransactionID, receipt.TransactionID,
receipt.ReceiptNumber, receipt.ReceiptNumber,
receipt.UI, receipt.UI,
@@ -85,16 +123,22 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
receipt.UNP, receipt.UNP,
receipt.Success, receipt.Success,
) }
log.Printf("SQL: %s", query)
log.Printf("ARGS: %+v", args)
var receiptID int64
err = tx.QueryRowContext(ctx, query, args...).Scan(&receiptID)
if err != nil { if err != nil {
if isReceiptAlreadyExistsError(err) {
return 0, ErrReceiptAlreadyExists
}
return 0, err return 0, err
} }
receiptID, err := res.LastInsertId() log.Println("Second query")
if err != nil {
return 0, err
}
stmt, err := tx.PrepareContext(ctx, ` stmt, err := tx.PrepareContext(ctx, `
INSERT INTO positions ( INSERT INTO positions (
@@ -109,7 +153,11 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
tag, tag,
marking_code, marking_code,
ukz_code ukz_code
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) )
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10, $11
)
`) `)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -117,7 +165,8 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
defer stmt.Close() defer stmt.Close()
for _, p := range receipt.Positions { for _, p := range receipt.Positions {
_, err = stmt.ExecContext(ctx, _, err = stmt.ExecContext(
ctx,
receiptID, receiptID,
p.SectionNumber, p.SectionNumber,
p.GTINCode, p.GTINCode,
@@ -135,7 +184,20 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
} }
} }
return receiptID, tx.Commit() if err = tx.Commit(); err != nil {
return 0, err
}
return receiptID, nil
}
func isReceiptAlreadyExistsError(err error) bool {
var pqErr *pq.Error
if !errors.As(err, &pqErr) {
return false
}
return pqErr.Code == "23505" && pqErr.Constraint == "receipts_receipt_number_key"
} }
func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Receipt, error) { func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Receipt, error) {
@@ -157,7 +219,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
doc_num, skno_number, unp, doc_num, skno_number, unp,
success success
FROM receipts FROM receipts
WHERE id = ? WHERE id = $1
`, id).Scan( `, id).Scan(
&receipt.ID, &receipt.ID,
&receipt.TransactionID, &receipt.TransactionID,
@@ -213,7 +275,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
product_count, amount, product_count, amount,
discount, surcharge, discount, surcharge,
tag, marking_code, ukz_code tag, marking_code, ukz_code
FROM positions WHERE receipt_id = ? FROM positions WHERE receipt_id = $1
`, id) `, id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -247,10 +309,16 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Receipt, error) { func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Receipt, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, transaction_id, receipt_number, issued_at, total_amount, currency SELECT
id,
transaction_id,
receipt_number,
issued_at,
total_amount,
currency
FROM receipts FROM receipts
ORDER BY issued_at DESC ORDER BY issued_at DESC
LIMIT ? OFFSET ? LIMIT $1 OFFSET $2
`, limit, offset) `, limit, offset)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -261,6 +329,7 @@ func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) (
for rows.Next() { for rows.Next() {
var rct domain.Receipt var rct domain.Receipt
if err := rows.Scan( if err := rows.Scan(
&rct.ID, &rct.ID,
&rct.TransactionID, &rct.TransactionID,
@@ -271,9 +340,14 @@ func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) (
); err != nil { ); err != nil {
return nil, err return nil, err
} }
receipts = append(receipts, &rct) receipts = append(receipts, &rct)
} }
if err := rows.Err(); err != nil {
return nil, err
}
return receipts, nil return receipts, nil
} }
@@ -287,11 +361,11 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
UPDATE receipts SET UPDATE receipts SET
transaction_id = ?, transaction_id = $1,
issued_at = ?, issued_at = $2,
total_amount = ?, total_amount = $3,
currency = ? currency = $4
WHERE id = ? WHERE id = $5
`, `,
receipt.TransactionID, receipt.TransactionID,
receipt.IssuedAt, receipt.IssuedAt,
@@ -303,7 +377,7 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
return err return err
} }
_, err = tx.ExecContext(ctx, `DELETE FROM positions WHERE receipt_id = ?`, receipt.ID) _, err = tx.ExecContext(ctx, `DELETE FROM positions WHERE receipt_id = $1`, receipt.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -312,7 +386,7 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
INSERT INTO positions ( INSERT INTO positions (
receipt_id, product_name, product_count, amount receipt_id, product_name, product_count, amount
) VALUES (?, ?, ?, ?) ) VALUES ($1, $2, $3, $4)
`, receipt.ID, p.ProductName, p.ProductCount, p.Amount) `, receipt.ID, p.ProductName, p.ProductCount, p.Amount)
if err != nil { if err != nil {
return err return err
@@ -324,7 +398,7 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
func (r *ReceiptsSQLRepository) Delete(ctx context.Context, id int64) error { func (r *ReceiptsSQLRepository) Delete(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`DELETE FROM receipts WHERE id = ?`, `DELETE FROM receipts WHERE id = $1`,
id, id,
) )
return err return err
+3 -3
View File
@@ -13,9 +13,9 @@ var knownDateFormats = []string{
"02.01.06", // 21.01.2026 "02.01.06", // 21.01.2026
"02-01-2006", // 21-01-2026 "02-01-2006", // 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) { func NormalizeDateToISO(input string) (string, error) {
+29
View File
@@ -0,0 +1,29 @@
package utils
import (
"encoding/json"
"fmt"
)
const DefaultLogValueLimit = 4096
func ToLogJSON(value any) string {
if value == nil {
return "null"
}
data, err := json.Marshal(value)
if err != nil {
return TruncateForLog(fmt.Sprintf("%+v", value), DefaultLogValueLimit)
}
return TruncateForLog(string(data), DefaultLogValueLimit)
}
func TruncateForLog(value string, limit int) string {
if limit <= 0 || len(value) <= limit {
return value
}
return value[:limit] + "...(truncated)"
}
+21 -6
View File
@@ -1,6 +1,10 @@
package utils package utils
import "regexp" import (
"regexp"
"strings"
"time"
)
type ReceiptMeta struct { type ReceiptMeta struct {
Date string Date string
@@ -10,17 +14,16 @@ type ReceiptMeta struct {
func ExtractReceiptMeta(text string) ReceiptMeta { func ExtractReceiptMeta(text string) ReceiptMeta {
result := ReceiptMeta{} result := ReceiptMeta{}
// --- ДАТА ---
datePatterns := []string{ datePatterns := []string{
`(\d{2}[./-]\d{2}[./-]\d{4})`, // 25.01.2026 `\b\d{2}[./:-]\d{2}[./:-]\d{4}\b`, // 25.01.2026, 25:01.2026
`(\d{2}[./-]\d{2}[./-]\d{2})`, // 25.01.26 `\b\d{4}[./:-]\d{2}[./:-]\d{2}\b`, // 2026-01-25
`(\d{4}[./-]\d{2}[./-]\d{2})`, // 2026-01-25 `\b\d{2}[./-]\d{2}[./-]\d{2}\b`, // 25.01.26
} }
for _, pattern := range datePatterns { for _, pattern := range datePatterns {
re := regexp.MustCompile(pattern) re := regexp.MustCompile(pattern)
if match := re.FindString(text); match != "" { if match := re.FindString(text); match != "" {
result.Date = match result.Date = normalizeOCRDate(match)
break break
} }
} }
@@ -31,3 +34,15 @@ func ExtractReceiptMeta(text string) ReceiptMeta {
return result 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)
}
}
+1
View File
@@ -0,0 +1 @@
25.8.2
+6 -1
View File
@@ -23,5 +23,10 @@
"overrides": { "overrides": {
"vite": "6.3.5" "vite": "6.3.5"
} }
} },
"engines": {
"node": "^25.5.2",
"npm": ">=10"
},
"packageManager": "npm@11.11.1"
} }
+59 -21
View File
@@ -1,23 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import Header from './components/Header.vue';
import Navigation from './components/Navigation.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 FinanceScreen from './components/FinanceScreen.vue';
import SettingsScreen from './components/SettingsScreen.vue'; import SettingsScreen from './components/SettingsScreen.vue';
import { getFamilyById } from './api/families'; 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 activeScreen = ref('home');
const previousScreen = ref('home'); const previousScreen = ref('home');
const familyName = ref<string | null>(null); 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 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) { function handleNavigate(screen: string) {
if (screen === 'settings') { if (screen === 'settings') {
@@ -44,6 +43,7 @@ async function loadFamily() {
try { try {
const family = await getFamilyById(configuredFamilyId); const family = await getFamilyById(configuredFamilyId);
familyName.value = family.name; familyName.value = family.name;
familyOwnerId.value = family.owner_id;
} catch (error) { } catch (error) {
console.error('Failed to load family', error); console.error('Failed to load family', error);
} }
@@ -52,33 +52,71 @@ async function loadFamily() {
onMounted(() => { onMounted(() => {
void loadFamily(); void loadFamily();
}); });
const resolvedUserId = computed(() => {
if (Number.isFinite(configuredUserId) && configuredUserId > 0) {
return configuredUserId;
}
return familyOwnerId.value;
});
</script> </script>
<template> <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 <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" @navigate="handleNavigate"
/> />
<!-- Settings Screen -->
<SettingsScreen <SettingsScreen
v-else-if="activeScreen === 'settings'" v-else-if="activeScreen === 'settings'"
@navigate="handleNavigate" @navigate="handleNavigate"
/> />
<div v-else class="min-h-screen bg-[#0A0A0F] dark"> <!-- Calendar Screen -->
<div class="mx-auto flex min-h-screen max-w-md flex-col relative"> <CalendarScreen
<Header :family-name="headerFamilyName" @navigate="handleNavigate" /> 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"> <!-- Intimacy Screen -->
<div class="space-y-4"> <IntimacyScreen
<BalanceWidget /> v-else-if="activeScreen === 'intimacy'"
<TodayWidget /> :family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
<SwipeCards /> @navigate="handleNavigate"
<RecentActivityWidget /> />
</div>
</main>
<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> </div>
<Navigation :active-screen="activeScreen" @navigate="handleNavigate" />
</div> </div>
</template> </template>
+107
View File
@@ -0,0 +1,107 @@
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 : []
}
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, Utensils,
Zap, Zap,
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import { useI18n } from '../i18n'; import { useI18n } from '@/i18n';
const { t } = useI18n(); const { t } = useI18n();
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { Eye, EyeOff, TrendingUp } from 'lucide-vue-next'; import { Eye, EyeOff, TrendingUp } from 'lucide-vue-next';
import { useI18n } from '../i18n'; import { useI18n } from '@/i18n';
const isVisible = ref(true); const isVisible = ref(true);
const chartData = [20000, 21500, 20800, 23200, 22500, 24850]; const chartData = [20000, 21500, 20800, 23200, 22500, 24850];
+44 -27
View File
@@ -1,21 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; 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 FinanceBalanceCard from './FinanceBalanceCard.vue';
import TransactionsList from './TransactionsList.vue'; import TransactionsList from './TransactionsList.vue';
import AnalyticsView from './AnalyticsView.vue'; import AnalyticsView from './AnalyticsView.vue';
import CategoriesView from './CategoriesView.vue'; import CategoriesView from './CategoriesView.vue';
import Navigation from './Navigation.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'; type Tab = 'transactions' | 'analytics' | 'categories';
const emit = defineEmits<{ const emit = defineEmits<{
navigate: [screen: string]; navigate: [screen: string];
}>(); }>();
defineProps<{
familyId?: number;
userId?: number;
}>();
const { t } = useI18n(); const { t } = useI18n();
const activeTab = ref<Tab>('transactions'); 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 }>>(() => [ const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
{ id: 'transactions', label: t('finance.tab.transactions') }, { id: 'transactions', label: t('finance.tab.transactions') },
@@ -25,31 +42,30 @@ const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
</script> </script>
<template> <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"> <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"> <HeaderWidget
<div class="flex items-center gap-3"> :icon="WalletIcon"
<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"> :eyebrow="t('finance.header.eyebrow')"
<WalletIcon class="h-5 w-5 text-white" :stroke-width="2.5" /> :title="t('finance.header.title')"
</div> @navigate="emit('navigate', $event)"
<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>
<main class="flex-1 overflow-y-auto px-5 pb-24"> <main class="flex-1 overflow-y-auto px-5 pb-24">
<div class="mb-5"> <div class="mb-5">
@@ -76,13 +92,14 @@ const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
</div> </div>
</div> </div>
<TransactionsList v-if="activeTab === 'transactions'" /> <TransactionsList v-if="activeTab === 'transactions'" :family-id="familyId" :key="transactionsListKey" />
<AnalyticsView v-else-if="activeTab === 'analytics'" /> <AnalyticsView v-else-if="activeTab === 'analytics'" />
<CategoriesView v-else /> <CategoriesView v-else />
</main> </main>
<button <button
type="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" 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" /> <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"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import type { Component } from 'vue';
import { Bell, Settings, User } from 'lucide-vue-next'; import { Bell, Settings } from 'lucide-vue-next';
import { useI18n } from '@/i18n';
defineProps<{
icon: Component;
eyebrow: string;
title: string;
}>();
const emit = defineEmits<{ const emit = defineEmits<{
navigate: [screen: string]; 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> </script>
<template> <template>
<header class="flex items-center justify-between px-5 pt-6 pb-4"> <header class="flex items-center justify-between px-5 pt-6 pb-4">
<div class="flex items-center gap-3"> <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"> <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>
<div> <div>
<p class="mb-0.5 text-[11px] font-normal text-zinc-500">{{ greeting }}</p> <p class="mb-0.5 text-[11px] font-normal text-zinc-500">{{ eyebrow }}</p>
<h1 class="text-[17px] font-semibold tracking-tight text-white">{{ props.familyName || t('header.familyName') }}</h1> <h1 class="text-[17px] font-semibold tracking-tight text-white">{{ title }}</h1>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <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"> <script setup lang="ts">
import {Calendar, Heart, Home, Sparkles, Wallet} from 'lucide-vue-next'; import {Calendar, Heart, Home, Sparkles, Wallet} from 'lucide-vue-next';
import { computed, type Component } from 'vue'; import { computed, type Component } from 'vue';
import { useI18n } from '../i18n'; import { useI18n } from '@/i18n';
interface NavItem { interface NavItem {
icon: Component; 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>
+244 -134
View File
@@ -1,21 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Component } from 'vue'; import { computed, ref, watch, type Component } from 'vue';
import { import {
Car, Car,
Coffee, Coffee,
CircleDollarSign,
Film, Film,
Heart, Heart,
Home, Home,
Receipt,
ShoppingBag, ShoppingBag,
TrendingUp, TrendingUp,
Utensils, Utensils,
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import { getTransactions, type Transaction as ApiTransaction } from '../api/transactions';
interface Transaction { interface TransactionViewModel {
id: string; id: number;
title: string; title: string;
categoryKey: string; categoryLabel: string;
amount: number; amount: number;
type: 'income' | 'expense'; type: 'income' | 'expense';
icon: Component; icon: Component;
@@ -24,12 +27,17 @@ interface Transaction {
} }
interface TransactionGroup { interface TransactionGroup {
dateKey: string; id: string;
label: string;
total: number; total: number;
transactions: Transaction[]; transactions: TransactionViewModel[];
} }
const { t } = useI18n(); const props = defineProps<{
familyId?: number;
}>();
const { locale, t } = useI18n();
const colorMap = { const colorMap = {
emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' }, emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' },
@@ -42,144 +50,246 @@ const colorMap = {
indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400' }, indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400' },
} as const; } as const;
const transactionGroups: TransactionGroup[] = [ const transactions = ref<ApiTransaction[]>([]);
{ const isLoading = ref(false);
dateKey: 'finance.transactions.today', const errorMessage = ref('');
total: -245.5,
transactions: [ const categoryPresentationMap: Record<string, { icon: Component; color: keyof typeof colorMap; labelKey?: string }> = {
{ groceries: { icon: ShoppingBag, color: 'emerald', labelKey: 'finance.category.groceries' },
id: '1', shopping: { icon: ShoppingBag, color: 'emerald' },
title: 'Whole Foods Market', food: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
categoryKey: 'finance.category.groceries', food_dining: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
amount: -124.5, dining: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
type: 'expense', coffee: { icon: Coffee, color: 'amber', labelKey: 'finance.category.coffee' },
icon: ShoppingBag, income: { icon: TrendingUp, color: 'blue', labelKey: 'finance.category.income' },
color: 'emerald', transport: { icon: Car, color: 'red', labelKey: 'finance.category.transport' },
time: '2:30 PM', entertainment: { icon: Film, color: 'purple', labelKey: 'finance.category.entertainment' },
}, donation: { icon: Heart, color: 'pink', labelKey: 'finance.category.donation' },
{ housing: { icon: Home, color: 'indigo', labelKey: 'finance.category.housing' },
id: '2', rent: { icon: Home, color: 'indigo' },
title: 'Uber Eats', receipt: { icon: Receipt, color: 'blue' },
categoryKey: 'finance.category.foodDining', };
amount: -45,
type: 'expense', const intlLocale = computed(() => (locale.value === 'ru' ? 'ru-RU' : 'en-US'));
icon: Utensils, const currencyFormatter = computed(() => new Intl.NumberFormat(intlLocale.value, {
color: 'orange', style: 'currency',
time: '12:15 PM', currency: 'USD',
}, minimumFractionDigits: 2,
{ maximumFractionDigits: 2,
id: '3', }));
title: 'Starbucks',
categoryKey: 'finance.category.coffee', const transactionGroups = computed<TransactionGroup[]>(() => {
amount: -12.5, const groups = new Map<string, TransactionGroup>();
type: 'expense',
icon: Coffee, for (const transaction of transactions.value) {
color: 'amber', const date = new Date(transaction.datetime);
time: '9:00 AM', const groupId = getGroupId(date);
}, const signedAmount = getSignedAmount(transaction);
{
id: '4', if (!groups.has(groupId)) {
title: 'Freelance Payment', groups.set(groupId, {
categoryKey: 'finance.category.income', id: groupId,
amount: 850, label: formatGroupLabel(date),
type: 'income', total: 0,
icon: TrendingUp, transactions: [],
color: 'blue', });
time: '8:00 AM', }
},
], const group = groups.get(groupId);
if (!group) {
continue;
}
const presentation = getCategoryPresentation(transaction.category, transaction.type);
group.total += signedAmount;
group.transactions.push({
id: transaction.id,
title: getTransactionTitle(transaction),
categoryLabel: getCategoryLabel(transaction.category),
amount: transaction.amount,
type: transaction.type === 'income' ? 'income' : 'expense',
icon: presentation.icon,
color: presentation.color,
time: formatTime(date),
});
}
return Array.from(groups.values());
});
async function loadTransactions() {
isLoading.value = true;
errorMessage.value = '';
try {
transactions.value = await getTransactions({ familyId: props.familyId });
} catch (error) {
console.error('Failed to load transactions', error);
errorMessage.value = t('finance.transactions.error');
transactions.value = [];
} finally {
isLoading.value = false;
}
}
function getSignedAmount(transaction: ApiTransaction): number {
return transaction.type === 'income' ? transaction.amount : -transaction.amount;
}
function getCategoryPresentation(category: string, type: string) {
const normalizedCategory = normalizeKey(category);
if (type === 'income') {
return categoryPresentationMap.income;
}
return categoryPresentationMap[normalizedCategory] ?? { icon: CircleDollarSign, color: 'blue' as const };
}
function getCategoryLabel(category: string): string {
const presentation = categoryPresentationMap[normalizeKey(category)];
if (presentation?.labelKey) {
return t(presentation.labelKey);
}
return humanizeCategory(category);
}
function getTransactionTitle(transaction: ApiTransaction): string {
const description = transaction.description?.trim();
if (description) {
return description;
}
return getCategoryLabel(transaction.category) || t('finance.transactions.untitled');
}
function getGroupId(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatGroupLabel(date: Date): string {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (isSameDate(date, today)) {
return t('finance.transactions.today');
}
if (isSameDate(date, yesterday)) {
return t('finance.transactions.yesterday');
}
const options: Intl.DateTimeFormatOptions = date.getFullYear() === today.getFullYear()
? { day: 'numeric', month: 'short' }
: { day: 'numeric', month: 'short', year: 'numeric' };
return new Intl.DateTimeFormat(intlLocale.value, options).format(date);
}
function formatTime(date: Date): string {
return new Intl.DateTimeFormat(intlLocale.value, {
hour: 'numeric',
minute: '2-digit',
}).format(date);
}
function formatAmount(amount: number): string {
return currencyFormatter.value.format(Math.abs(amount));
}
function isSameDate(left: Date, right: Date): boolean {
return left.getFullYear() === right.getFullYear()
&& left.getMonth() === right.getMonth()
&& left.getDate() === right.getDate();
}
function normalizeKey(value: string): string {
return value.trim().toLowerCase().replace(/[\s-]+/g, '_');
}
function humanizeCategory(value: string): string {
return value
.trim()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
watch(
() => props.familyId,
() => {
void loadTransactions();
}, },
{ { immediate: true },
dateKey: 'finance.transactions.yesterday', );
total: -89.99,
transactions: [
{
id: '5',
title: 'Shell Gas Station',
categoryKey: 'finance.category.transport',
amount: -65,
type: 'expense',
icon: Car,
color: 'red',
time: '6:45 PM',
},
{
id: '6',
title: 'Netflix Subscription',
categoryKey: 'finance.category.entertainment',
amount: -15.99,
type: 'expense',
icon: Film,
color: 'purple',
time: '12:00 PM',
},
{
id: '7',
title: 'Charity Donation',
categoryKey: 'finance.category.donation',
amount: -25,
type: 'expense',
icon: Heart,
color: 'pink',
time: '10:30 AM',
},
],
},
{
dateKey: 'finance.transactions.apr1',
total: -1250,
transactions: [
{
id: '8',
title: 'Rent Payment',
categoryKey: 'finance.category.housing',
amount: -1250,
type: 'expense',
icon: Home,
color: 'indigo',
time: '9:00 AM',
},
],
},
];
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<div v-for="group in transactionGroups" :key="group.dateKey"> <div
<div class="mb-3 flex items-center justify-between px-1"> v-if="isLoading"
<h3 class="text-[14px] font-semibold text-white">{{ t(group.dateKey) }}</h3> class="rounded-[16px] border border-white/[0.06] bg-[#16161F] px-4 py-6 text-center text-[14px] text-zinc-400"
<span :class="['text-[13px] font-semibold', group.total >= 0 ? 'text-emerald-400' : 'text-zinc-400']"> >
{{ group.total >= 0 ? '+' : '' }}${{ Math.abs(group.total).toFixed(2) }} {{ t('finance.transactions.loading') }}
</span> </div>
</div>
<div class="space-y-2"> <div
<div v-else-if="errorMessage"
v-for="transaction in group.transactions" class="rounded-[16px] border border-rose-500/20 bg-rose-500/10 px-4 py-6 text-center text-[14px] text-rose-200"
: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]" {{ errorMessage }}
> </div>
<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 class="min-w-0 flex-1"> <div
<p class="mb-0.5 truncate text-[14px] font-semibold text-white">{{ transaction.title }}</p> v-else-if="transactionGroups.length === 0"
<div class="flex items-center gap-2"> class="rounded-[16px] border border-white/[0.06] bg-[#16161F] px-4 py-6 text-center text-[14px] text-zinc-400"
<span class="text-[12px] text-zinc-500">{{ t(transaction.categoryKey) }}</span> >
<span class="text-zinc-700"></span> {{ t('finance.transactions.empty') }}
<span class="text-[12px] text-zinc-600">{{ transaction.time }}</span> </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>
<div class="text-right"> <div class="min-w-0 flex-1">
<p :class="['text-[15px] font-bold', transaction.type === 'income' ? 'text-emerald-400' : 'text-white']"> <p class="mb-0.5 truncate text-[14px] font-semibold text-white">{{ transaction.title }}</p>
{{ transaction.type === 'income' ? '+' : '-' }}${{ Math.abs(transaction.amount).toFixed(2) }} <div class="flex items-center gap-2">
</p> <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>
</div> </div>
</div> </template>
</div> </div>
</template> </template>
+78
View File
@@ -75,6 +75,10 @@ const messages: Record<Locale, Messages> = {
'finance.transactions.today': 'Today', 'finance.transactions.today': 'Today',
'finance.transactions.yesterday': 'Yesterday', 'finance.transactions.yesterday': 'Yesterday',
'finance.transactions.apr1': 'Apr 1', 'finance.transactions.apr1': 'Apr 1',
'finance.transactions.loading': 'Loading transactions...',
'finance.transactions.empty': 'No transactions yet',
'finance.transactions.error': 'Failed to load transactions',
'finance.transactions.untitled': 'Transaction',
'finance.category.groceries': 'Groceries', 'finance.category.groceries': 'Groceries',
'finance.category.foodDining': 'Food & Dining', 'finance.category.foodDining': 'Food & Dining',
'finance.category.coffee': 'Coffee', 'finance.category.coffee': 'Coffee',
@@ -109,11 +113,43 @@ const messages: Record<Locale, Messages> = {
'finance.categories.entertainment': 'Entertainment', 'finance.categories.entertainment': 'Entertainment',
'finance.categories.coffee': 'Coffee', 'finance.categories.coffee': 'Coffee',
'finance.categories.billsUtilities': 'Bills & Utilities', 'finance.categories.billsUtilities': 'Bills & Utilities',
'finance.categories.healthcare': 'Healthcare',
'finance.categories.work': 'Work', 'finance.categories.work': 'Work',
'finance.categories.giftsDonations': 'Gifts & Donations', 'finance.categories.giftsDonations': 'Gifts & Donations',
'finance.categories.others': 'Others', 'finance.categories.others': 'Others',
'finance.categories.of': 'of', '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.eyebrow': 'Manage your family hub',
'settings.header.title': 'Settings', 'settings.header.title': 'Settings',
'settings.profile.role': 'Family Admin', 'settings.profile.role': 'Family Admin',
@@ -148,6 +184,9 @@ const messages: Record<Locale, Messages> = {
'settings.module.active': 'Active', 'settings.module.active': 'Active',
'settings.module.disabled': 'Disabled', 'settings.module.disabled': 'Disabled',
'settings.signOut': 'Sign Out', 'settings.signOut': 'Sign Out',
'calendar.header.eyebrow': 'Plan your week',
'calendar.header.title': 'Calendar',
}, },
ru: { ru: {
'language.english': 'Английский', 'language.english': 'Английский',
@@ -217,6 +256,10 @@ const messages: Record<Locale, Messages> = {
'finance.transactions.today': 'Сегодня', 'finance.transactions.today': 'Сегодня',
'finance.transactions.yesterday': 'Вчера', 'finance.transactions.yesterday': 'Вчера',
'finance.transactions.apr1': '1 апр', 'finance.transactions.apr1': '1 апр',
'finance.transactions.loading': 'Загрузка транзакций...',
'finance.transactions.empty': 'Транзакций пока нет',
'finance.transactions.error': 'Не удалось загрузить транзакции',
'finance.transactions.untitled': 'Транзакция',
'finance.category.groceries': 'Продукты', 'finance.category.groceries': 'Продукты',
'finance.category.foodDining': 'Еда и рестораны', 'finance.category.foodDining': 'Еда и рестораны',
'finance.category.coffee': 'Кофе', 'finance.category.coffee': 'Кофе',
@@ -251,11 +294,43 @@ const messages: Record<Locale, Messages> = {
'finance.categories.entertainment': 'Развлечения', 'finance.categories.entertainment': 'Развлечения',
'finance.categories.coffee': 'Кофе', 'finance.categories.coffee': 'Кофе',
'finance.categories.billsUtilities': 'Счета и коммунальные', 'finance.categories.billsUtilities': 'Счета и коммунальные',
'finance.categories.healthcare': 'Здоровье',
'finance.categories.work': 'Работа', 'finance.categories.work': 'Работа',
'finance.categories.giftsDonations': 'Подарки и пожертвования', 'finance.categories.giftsDonations': 'Подарки и пожертвования',
'finance.categories.others': 'Прочее', 'finance.categories.others': 'Прочее',
'finance.categories.of': 'из', '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.eyebrow': 'Управляйте семейным хабом',
'settings.header.title': 'Настройки', 'settings.header.title': 'Настройки',
'settings.profile.role': 'Администратор семьи', 'settings.profile.role': 'Администратор семьи',
@@ -290,6 +365,9 @@ const messages: Record<Locale, Messages> = {
'settings.module.active': 'Активен', 'settings.module.active': 'Активен',
'settings.module.disabled': 'Отключён', 'settings.module.disabled': 'Отключён',
'settings.signOut': 'Выйти', '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 { interface ImportMetaEnv {
readonly VITE_FAMILY_ID?: string readonly VITE_FAMILY_ID?: string
readonly VITE_USER_ID?: string
} }
interface ImportMeta { interface ImportMeta {
+46
View File
@@ -0,0 +1,46 @@
# ================================
# 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 /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"]
-24
View File
@@ -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
+55
View File
@@ -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:
-5
View File
@@ -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/*
+62
View File
@@ -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
+15
View File
@@ -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"
+19
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: family-hub
+62
View File
@@ -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
+8
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
{
"id": 0,
"transaction_id": null,
"STATUS": 1,
"another_amount": 0,
"cash_amount": 0,
"cashbox_number": 119091676,
"cashier": "Старший кассир КСО",
"clearing_amount": 190.06,
"currency": "BYN",
"doc_num": "39972",
"house_to": "11",
"kod_soato": "5000000000",
"margin": 0,
"name_np": "Минск",
"name_spd": "Общество с ограниченной ответственностью \"ГРИНрозница\"",
"name_to": "Магазин \"ГРИН-5\"",
"oblast_soato": null,
"rayon_soato": null,
"selsovet_soato": null,
"payment_amount": 190.06,
"payment_type": 1,
"receipt_number": "CBB580D6268681CB071931DC",
"skno_number": "AVQ24170126963",
"street_to": "УЛ. ПЕТРА МСТИСЛАВЦА",
"success": "A check is correct",
"total_amount": 190.06,
"type_np": "г.",
"ui": "CBB580D6268681CB071931DC",
"unp": "191634233",
"issued_at": "09/11/2025, 18:08:31"
}