Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93506a2038 | |||
| 64fef9f674 | |||
| 97d923142e |
@@ -5,9 +5,11 @@ import (
|
||||
"FamilyHub/src/api/requests"
|
||||
"FamilyHub/src/api/services"
|
||||
receiptServiceIntegration "FamilyHub/src/integrations/receiptProvider"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
@@ -38,27 +40,7 @@ func logInternalError(c *gin.Context, scope string, err error) {
|
||||
}
|
||||
|
||||
func handleReceiptError(c *gin.Context, err error) {
|
||||
var externalErr *receiptServiceIntegration.ExternalServiceError
|
||||
|
||||
switch {
|
||||
case errors.Is(err, receiptServiceIntegration.ErrReceiptNotFound):
|
||||
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
|
||||
case errors.As(err, &externalErr):
|
||||
log.Printf(
|
||||
"receipt external service error: method=%s path=%s upstream_status=%d upstream_body=%q",
|
||||
c.Request.Method,
|
||||
c.Request.URL.Path,
|
||||
externalErr.StatusCode,
|
||||
externalErr.Body,
|
||||
)
|
||||
logError(c, "receipt external service", err)
|
||||
switch externalErr.StatusCode {
|
||||
case http.StatusForbidden, http.StatusTooManyRequests:
|
||||
c.JSON(http.StatusServiceUnavailable, dto.ErrorResponse{Message: "receipt service temporarily unavailable"})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, dto.ErrorResponse{Message: "receipt service error"})
|
||||
}
|
||||
default:
|
||||
if !handleReceiptProviderError(c, err) {
|
||||
logInternalError(c, "receipt request", err)
|
||||
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
|
||||
}
|
||||
@@ -96,3 +78,41 @@ func handleUserError(c *gin.Context, err error) {
|
||||
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
|
||||
}
|
||||
}
|
||||
|
||||
func handleReceiptProviderError(c *gin.Context, err error) bool {
|
||||
var externalErr *receiptServiceIntegration.ExternalServiceError
|
||||
|
||||
switch {
|
||||
case errors.Is(err, services.ErrReceiptNotFound),
|
||||
errors.Is(err, receiptServiceIntegration.ErrReceiptNotFound):
|
||||
logError(c, "receipt request", err)
|
||||
c.JSON(http.StatusUnprocessableEntity, dto.ErrorResponse{Message: err.Error()})
|
||||
return true
|
||||
case isTimeoutError(err):
|
||||
logError(c, "receipt request", err)
|
||||
c.JSON(http.StatusGatewayTimeout, dto.ErrorResponse{Message: "receipt service timeout"})
|
||||
return true
|
||||
case errors.As(err, &externalErr):
|
||||
log.Printf(
|
||||
"receipt external service error: method=%s path=%s upstream_status=%d upstream_body=%q",
|
||||
c.Request.Method,
|
||||
c.Request.URL.Path,
|
||||
externalErr.StatusCode,
|
||||
externalErr.Body,
|
||||
)
|
||||
logError(c, "receipt external service", err)
|
||||
c.JSON(http.StatusServiceUnavailable, dto.ErrorResponse{Message: "receipt service unavailable"})
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isTimeoutError(err error) bool {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
|
||||
var netErr net.Error
|
||||
return errors.As(err, &netErr) && netErr.Timeout()
|
||||
}
|
||||
|
||||
@@ -65,12 +65,14 @@ func (router *TransactionsRouter) Create(c *gin.Context) {
|
||||
|
||||
var req dto.CreateTransactionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logError(c, "transaction request validation", err)
|
||||
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
input, err := requests.BuildCreateTransactionInput(req)
|
||||
if err != nil {
|
||||
logError(c, "transaction request validation", err)
|
||||
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -87,6 +89,7 @@ func (router *TransactionsRouter) Create(c *gin.Context) {
|
||||
func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("photo")
|
||||
if err != nil {
|
||||
logError(c, "transaction request validation", err)
|
||||
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"})
|
||||
return
|
||||
}
|
||||
@@ -108,11 +111,13 @@ func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
|
||||
|
||||
familyID, err := parseOptionalInt64Form(c, "family_id")
|
||||
if err != nil {
|
||||
logError(c, "transaction request validation", err)
|
||||
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
||||
return
|
||||
}
|
||||
createdBy, err := parseOptionalInt64Form(c, "created_by")
|
||||
if err != nil {
|
||||
logError(c, "transaction request validation", err)
|
||||
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -126,6 +131,7 @@ func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
|
||||
Description: parseOptionalStringForm(c, "description"),
|
||||
})
|
||||
if err != nil {
|
||||
logError(c, "transaction request validation", err)
|
||||
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -335,6 +341,10 @@ func (router *TransactionsRouter) Delete(c *gin.Context) {
|
||||
}
|
||||
|
||||
func handleTransactionError(c *gin.Context, err error) {
|
||||
if handleReceiptProviderError(c, err) {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, services.ErrTransactionNotFound):
|
||||
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
|
||||
@@ -352,8 +362,6 @@ func handleTransactionError(c *gin.Context, err error) {
|
||||
errors.Is(err, services.ErrOCRNotConfigured),
|
||||
errors.Is(err, services.ErrReceiptTransactionNotCreated):
|
||||
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: err.Error()})
|
||||
case errors.Is(err, services.ErrReceiptNotFound):
|
||||
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
|
||||
default:
|
||||
logInternalError(c, "transaction request", err)
|
||||
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"FamilyHub/src/api/requests"
|
||||
"FamilyHub/src/api/services"
|
||||
"FamilyHub/src/domain"
|
||||
receiptProvider "FamilyHub/src/integrations/receiptProvider"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -199,6 +200,54 @@ func TestTransactionsRouter_Create(t *testing.T) {
|
||||
assert.Contains(t, w.Body.String(), `"id":21`)
|
||||
})
|
||||
|
||||
t.Run("creates transaction from receipt with iso date", func(t *testing.T) {
|
||||
r := gin.New()
|
||||
apiV1 := r.Group("/api/v1")
|
||||
|
||||
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
|
||||
assert.Equal(t, validNumber, req.Number)
|
||||
assert.Equal(t, "2025-11-09", req.Date)
|
||||
require.NotNil(t, req.FamilyID)
|
||||
require.NotNil(t, req.CreatedBy)
|
||||
assert.Equal(t, int64(1), *req.FamilyID)
|
||||
assert.Equal(t, int64(1), *req.CreatedBy)
|
||||
return &domain.Receipt{ID: 9, ReceiptNumber: validNumber, IssuedAt: now, TransactionID: ptrInt64(23)}, nil
|
||||
}}
|
||||
service := &transactionServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) {
|
||||
assert.Equal(t, int64(23), id)
|
||||
return &domain.Transaction{
|
||||
ID: 23,
|
||||
FamilyID: 1,
|
||||
Type: "expense",
|
||||
DateTime: now,
|
||||
Category: "receipt",
|
||||
Amount: 89.4,
|
||||
CreatedBy: 1,
|
||||
CreatedAt: now,
|
||||
ReceiptID: ptrInt64(9),
|
||||
}, nil
|
||||
}}
|
||||
|
||||
creationService := services.NewTransactionCreationService(service, receiptSvc, nil)
|
||||
router := NewTransactionsRouter(service, creationService)
|
||||
router.RegisterRoutes(apiV1)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{
|
||||
"family_id":1,
|
||||
"created_by":1,
|
||||
"receipt_number":"`+validNumber+`",
|
||||
"receipt_date":"2025-11-09",
|
||||
"type":"expense"
|
||||
}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
assert.Contains(t, w.Body.String(), `"id":23`)
|
||||
})
|
||||
|
||||
t.Run("creates transaction from photo upload", func(t *testing.T) {
|
||||
r := gin.New()
|
||||
apiV1 := r.Group("/api/v1")
|
||||
@@ -290,6 +339,84 @@ func TestTransactionsRouter_Create(t *testing.T) {
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "family_id and created_by are required for receipt transaction")
|
||||
})
|
||||
|
||||
t.Run("returns 422 when receipt is not found in photo flow", func(t *testing.T) {
|
||||
r := gin.New()
|
||||
apiV1 := r.Group("/api/v1")
|
||||
|
||||
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
|
||||
return "21.01.2026 " + validNumber, nil
|
||||
}}
|
||||
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
|
||||
return nil, receiptProvider.ErrReceiptNotFound
|
||||
}}
|
||||
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc)
|
||||
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
|
||||
router.RegisterRoutes(apiV1)
|
||||
|
||||
req := newMultipartRequest(t, map[string]string{
|
||||
"family_id": "1",
|
||||
"created_by": "2",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusUnprocessableEntity, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "receipt not found")
|
||||
})
|
||||
|
||||
t.Run("returns 503 when receipt provider is unavailable in photo flow", func(t *testing.T) {
|
||||
r := gin.New()
|
||||
apiV1 := r.Group("/api/v1")
|
||||
|
||||
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
|
||||
return "21.01.2026 " + validNumber, nil
|
||||
}}
|
||||
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
|
||||
return nil, &receiptProvider.ExternalServiceError{StatusCode: http.StatusBadGateway, Body: "upstream failed"}
|
||||
}}
|
||||
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc)
|
||||
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
|
||||
router.RegisterRoutes(apiV1)
|
||||
|
||||
req := newMultipartRequest(t, map[string]string{
|
||||
"family_id": "1",
|
||||
"created_by": "2",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "receipt service unavailable")
|
||||
})
|
||||
|
||||
t.Run("returns 504 when receipt provider times out in photo flow", func(t *testing.T) {
|
||||
r := gin.New()
|
||||
apiV1 := r.Group("/api/v1")
|
||||
|
||||
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
|
||||
return "21.01.2026 " + validNumber, nil
|
||||
}}
|
||||
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
}}
|
||||
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc)
|
||||
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
|
||||
router.RegisterRoutes(apiV1)
|
||||
|
||||
req := newMultipartRequest(t, map[string]string{
|
||||
"family_id": "1",
|
||||
"created_by": "2",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusGatewayTimeout, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "receipt service timeout")
|
||||
})
|
||||
}
|
||||
|
||||
func ptrInt64(v int64) *int64 {
|
||||
|
||||
@@ -129,7 +129,7 @@ func NewServer(cfg config.Config) *Server {
|
||||
authRouter.RegisterRouter(apiV1)
|
||||
|
||||
// подключаем статику Vue — должно быть последним
|
||||
registerStaticFiles(router)
|
||||
registerStaticFiles(router, "src/api/dist")
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
Addr: cfg.APIHost + ":" + cfg.APIPort,
|
||||
|
||||
@@ -119,7 +119,7 @@ func buildReceiptTransactionDescription(receipt *domain.Receipt, explicit *strin
|
||||
return &value
|
||||
}
|
||||
|
||||
if name := strings.TrimSpace(receipt.NameSPD); name != "" {
|
||||
if name := strings.TrimSpace(receipt.NameTO); name != "" {
|
||||
return &name
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"FamilyHub/src/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildReceiptTransactionDescription_UsesNameTO(t *testing.T) {
|
||||
receipt := &domain.Receipt{
|
||||
NameSPD: "Old merchant name",
|
||||
NameTO: "Expected terminal name",
|
||||
ReceiptNumber: "77F05E82C1ED044B07194794",
|
||||
}
|
||||
|
||||
description := buildReceiptTransactionDescription(receipt, nil)
|
||||
if description == nil {
|
||||
t.Fatal("expected description to be set")
|
||||
}
|
||||
|
||||
if *description != "Expected terminal name" {
|
||||
t.Fatalf("expected description %q, got %q", "Expected terminal name", *description)
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -1,26 +1,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
var staticFiles embed.FS
|
||||
func registerStaticFiles(router *gin.Engine, staticDir string) {
|
||||
if _, err := os.Stat(staticDir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
c.Status(http.StatusNotFound)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func registerStaticFiles(router *gin.Engine) {
|
||||
// вырезаем префикс dist/ чтобы / отдавал index.html
|
||||
distFS, err := fs.Sub(staticFiles, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.FS(distFS))
|
||||
fileServer := http.FileServer(http.Dir(staticDir))
|
||||
|
||||
// все маршруты которые не /api и не /openapi — отдаём Vue
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRegisterStaticFilesReturns404WhenDirectoryIsMissing(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
registerStaticFiles(router, filepath.Join(t.TempDir(), "missing-dist"))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/some-route", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
}
|
||||
|
||||
func TestRegisterStaticFilesServesExistingDirectory(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
staticDir := t.TempDir()
|
||||
indexPath := filepath.Join(staticDir, "index.html")
|
||||
require.NoError(t, os.WriteFile(indexPath, []byte("ok"), 0o644))
|
||||
|
||||
router := gin.New()
|
||||
registerStaticFiles(router, staticDir)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, "ok", recorder.Body.String())
|
||||
}
|
||||
@@ -172,7 +172,12 @@ func buildMultipartBody(date, number string) (*bytes.Buffer, string) {
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
_ = writer.WriteField("orig_date", date)
|
||||
normalizedDate := strings.TrimSpace(date)
|
||||
if isoDate, err := utils.NormalizeDateToISO(normalizedDate); err == nil {
|
||||
normalizedDate = isoDate
|
||||
}
|
||||
|
||||
_ = writer.WriteField("orig_date", normalizedDate)
|
||||
_ = writer.WriteField("orig_ui", number)
|
||||
|
||||
_ = writer.Close()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package receiptProvider
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildMultipartBody_NormalizesDateToISO(t *testing.T) {
|
||||
body, _ := buildMultipartBody("07.04.2026", "77F05E82C1ED044B07194794")
|
||||
payload := body.String()
|
||||
|
||||
if !strings.Contains(payload, "2026-04-07") {
|
||||
t.Fatalf("expected ISO date in multipart body, got %q", payload)
|
||||
}
|
||||
|
||||
if strings.Contains(payload, "07.04.2026") {
|
||||
t.Fatalf("did not expect source date format in multipart body, got %q", payload)
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@ var knownDateFormats = []string{
|
||||
"02.01.06", // 21.01.2026
|
||||
"02-01-2006", // 21-01-2026
|
||||
"02/01/2006", // 21/01/2026
|
||||
//"2006/01/02", // 2026/01/21
|
||||
//"2006-01-02", // 2026-01-21
|
||||
//"2006.01.02", // 2026.01.21
|
||||
"2006/01/02", // 2026/01/21
|
||||
"2006-01-02", // 2026-01-21
|
||||
"2006.01.02", // 2026.01.21
|
||||
}
|
||||
|
||||
func NormalizeDateToISO(input string) (string, error) {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package utils
|
||||
|
||||
import "regexp"
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ReceiptMeta struct {
|
||||
Date string
|
||||
@@ -10,17 +14,16 @@ type ReceiptMeta struct {
|
||||
func ExtractReceiptMeta(text string) ReceiptMeta {
|
||||
result := ReceiptMeta{}
|
||||
|
||||
// --- ДАТА ---
|
||||
datePatterns := []string{
|
||||
`(\d{2}[./-]\d{2}[./-]\d{4})`, // 25.01.2026
|
||||
`(\d{2}[./-]\d{2}[./-]\d{2})`, // 25.01.26
|
||||
`(\d{4}[./-]\d{2}[./-]\d{2})`, // 2026-01-25
|
||||
`\b\d{2}[./:-]\d{2}[./:-]\d{4}\b`, // 25.01.2026, 25:01.2026
|
||||
`\b\d{4}[./:-]\d{2}[./:-]\d{2}\b`, // 2026-01-25
|
||||
`\b\d{2}[./-]\d{2}[./-]\d{2}\b`, // 25.01.26
|
||||
}
|
||||
|
||||
for _, pattern := range datePatterns {
|
||||
re := regexp.MustCompile(pattern)
|
||||
if match := re.FindString(text); match != "" {
|
||||
result.Date = match
|
||||
result.Date = normalizeOCRDate(match)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -31,3 +34,15 @@ func ExtractReceiptMeta(text string) ReceiptMeta {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeOCRDate(value string) string {
|
||||
sanitized := strings.NewReplacer(":", ".", "/", ".", "-", ".").Replace(strings.TrimSpace(value))
|
||||
|
||||
for _, layout := range knownDateFormats {
|
||||
if t, err := time.Parse(layout, sanitized); err == nil {
|
||||
return t.Format("02.01.2006")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestExtractReceiptMeta_NormalizesOCRDateWithColon(t *testing.T) {
|
||||
text := "Двойной Американо\n1762992079489\n1,000 x 5,50\n5,50\nИТОГО К ОПЛАТЕ:\n5,50\nБП Карта:\n5,50\nКассир:\nср Кто все эти л\nДата и время:\n07:04.2026 08:57:14\nУИ:\n77F05E82C1ED044B07194794"
|
||||
|
||||
meta := ExtractReceiptMeta(text)
|
||||
|
||||
if meta.Date != "07.04.2026" {
|
||||
t.Fatalf("expected normalized date %q, got %q", "07.04.2026", meta.Date)
|
||||
}
|
||||
|
||||
if meta.ReceiptID != "77F05E82C1ED044B07194794" {
|
||||
t.Fatalf("expected receipt id %q, got %q", "77F05E82C1ED044B07194794", meta.ReceiptID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractReceiptMeta_DoesNotTreatTimeAsDate(t *testing.T) {
|
||||
meta := ExtractReceiptMeta("Дата и время:\n08:57:14\nУИ:\n77F05E82C1ED044B07194794")
|
||||
|
||||
if meta.Date != "" {
|
||||
t.Fatalf("expected empty date, got %q", meta.Date)
|
||||
}
|
||||
}
|
||||
+58
-21
@@ -1,23 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import Header from './components/Header.vue';
|
||||
import Navigation from './components/Navigation.vue';
|
||||
import BalanceWidget from './components/BalanceWidget.vue';
|
||||
import TodayWidget from './components/TodayWidget.vue';
|
||||
import RecentActivityWidget from './components/RecentActivityWidget.vue';
|
||||
import SwipeCards from './components/SwipeCards.vue';
|
||||
import FinanceScreen from './components/FinanceScreen.vue';
|
||||
import SettingsScreen from './components/SettingsScreen.vue';
|
||||
import { getFamilyById } from './api/families';
|
||||
import { useI18n } from './i18n';
|
||||
import HomeScreen from "@/components/HomeScreen.vue";
|
||||
import CalendarScreen from "@/components/CalendarScreen.vue";
|
||||
import IntimacyScreen from "@/components/IntimacyScreen.vue";
|
||||
import {Heart} from "lucide-vue-next";
|
||||
|
||||
const activeScreen = ref('home');
|
||||
const previousScreen = ref('home');
|
||||
const familyName = ref<string | null>(null);
|
||||
const { t } = useI18n();
|
||||
const familyOwnerId = ref<number | null>(null);
|
||||
|
||||
const configuredFamilyId = Number.parseInt(import.meta.env.VITE_FAMILY_ID ?? '1', 10);
|
||||
const headerFamilyName = computed(() => familyName.value?.trim() || t('header.familyName'));
|
||||
const configuredUserId = Number.parseInt(import.meta.env.VITE_USER_ID ?? '', 10);
|
||||
|
||||
|
||||
function handleNavigate(screen: string) {
|
||||
if (screen === 'settings') {
|
||||
@@ -44,6 +43,7 @@ async function loadFamily() {
|
||||
try {
|
||||
const family = await getFamilyById(configuredFamilyId);
|
||||
familyName.value = family.name;
|
||||
familyOwnerId.value = family.owner_id;
|
||||
} catch (error) {
|
||||
console.error('Failed to load family', error);
|
||||
}
|
||||
@@ -52,34 +52,71 @@ async function loadFamily() {
|
||||
onMounted(() => {
|
||||
void loadFamily();
|
||||
});
|
||||
|
||||
const resolvedUserId = computed(() => {
|
||||
if (Number.isFinite(configuredUserId) && configuredUserId > 0) {
|
||||
return configuredUserId;
|
||||
}
|
||||
|
||||
return familyOwnerId.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Home Screen -->
|
||||
<HomeScreen
|
||||
v-if="activeScreen === 'home'"
|
||||
:family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
|
||||
:on-navigate="handleNavigate"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<!-- Finance Screen -->
|
||||
<FinanceScreen
|
||||
v-if="activeScreen === 'finance'"
|
||||
v-else-if="activeScreen === 'finance'"
|
||||
:family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
|
||||
:user-id="resolvedUserId ?? undefined"
|
||||
:on-navigate="handleNavigate"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<!-- Settings Screen -->
|
||||
<SettingsScreen
|
||||
v-else-if="activeScreen === 'settings'"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<div v-else class="min-h-screen bg-[#0A0A0F] dark">
|
||||
<div class="mx-auto flex min-h-screen max-w-md flex-col relative">
|
||||
<Header :family-name="headerFamilyName" @navigate="handleNavigate" />
|
||||
<!-- Calendar Screen -->
|
||||
<CalendarScreen
|
||||
v-else-if="activeScreen === 'calendar'"
|
||||
:family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<main class="flex-1 overflow-y-auto px-5 pb-32">
|
||||
<div class="space-y-4">
|
||||
<BalanceWidget />
|
||||
<TodayWidget />
|
||||
<SwipeCards />
|
||||
<RecentActivityWidget />
|
||||
</div>
|
||||
</main>
|
||||
<!-- Intimacy Screen -->
|
||||
<IntimacyScreen
|
||||
v-else-if="activeScreen === 'intimacy'"
|
||||
:family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<Navigation :active-screen="activeScreen" @navigate="handleNavigate" />
|
||||
<!-- Fallback for other screens -->
|
||||
<div v-else class="min-h-screen bg-[#0A0A0F] dark flex items-center justify-center">
|
||||
<div class="text-center px-8">
|
||||
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-purple-500 to-blue-600 flex items-center justify-center mx-auto mb-4">
|
||||
<Heart :size="32" :stroke-width="2" class="text-white" />
|
||||
</div>
|
||||
<h2 class="text-white text-[20px] font-bold mb-2">{{ activeScreen.charAt(0).toUpperCase() + activeScreen.slice(1) }}</h2>
|
||||
<p class="text-zinc-400 text-[14px] mb-6">
|
||||
This screen is coming soon!
|
||||
</p>
|
||||
<button
|
||||
@click="handleNavigate('home')"
|
||||
class="px-6 py-3 rounded-[14px] bg-gradient-to-br from-purple-500 to-blue-600 text-white text-[14px] font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all"
|
||||
>
|
||||
Go to Home
|
||||
</button>
|
||||
</div>
|
||||
<Navigation :active-screen="activeScreen" @navigate="handleNavigate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,3 +41,67 @@ export async function getTransactions(options: GetTransactionsOptions = {}): Pro
|
||||
const payload = await response.json() as TransactionsResponse
|
||||
return Array.isArray(payload.items) ? payload.items : []
|
||||
}
|
||||
|
||||
export interface CreateTransactionData {
|
||||
family_id: number
|
||||
type?: string
|
||||
category?: string
|
||||
amount?: number
|
||||
datetime?: string
|
||||
description?: string
|
||||
receipt_number?: string
|
||||
receipt_date?: string
|
||||
}
|
||||
|
||||
// TODO: Replace with the authenticated user id when frontend auth is implemented.
|
||||
const TRANSACTION_CREATOR_ID = 1
|
||||
|
||||
export async function createTransaction(data: CreateTransactionData): Promise<Transaction> {
|
||||
const response = await fetch('/api/v1/transactions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
created_by: TRANSACTION_CREATOR_ID,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: response.statusText }))
|
||||
throw new Error(error.message || `Failed to create transaction: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json() as Promise<Transaction>
|
||||
}
|
||||
|
||||
export interface CreateTransactionPhotoData {
|
||||
photo: File
|
||||
family_id: number
|
||||
type?: string
|
||||
category?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export async function createTransactionFromPhoto(data: CreateTransactionPhotoData): Promise<Transaction> {
|
||||
const formData = new FormData()
|
||||
formData.append('photo', data.photo)
|
||||
formData.append('family_id', String(data.family_id))
|
||||
formData.append('created_by', String(TRANSACTION_CREATOR_ID))
|
||||
if (data.type) formData.append('type', data.type)
|
||||
if (data.category) formData.append('category', data.category)
|
||||
if (data.description) formData.append('description', data.description)
|
||||
|
||||
const response = await fetch('/api/v1/transactions', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: response.statusText }))
|
||||
throw new Error(error.message || `Failed to create transaction from photo: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json() as Promise<Transaction>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#0A0A0F] dark">
|
||||
<div class="mx-auto max-w-md min-h-screen flex flex-col relative">
|
||||
<header
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: -20 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
class="flex items-center justify-between px-5 pt-6 pb-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-11 h-11 rounded-[16px] bg-gradient-to-br from-purple-500 to-blue-600 flex items-center justify-center shadow-lg shadow-purple-500/20">
|
||||
<DollarSign :size="20" :stroke-width="2.5" class="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] text-zinc-500 font-normal mb-0.5">{{ t('finance.add.eyebrow') }}</p>
|
||||
<h1 class="text-[17px] text-white font-semibold tracking-tight">{{ t('finance.add.title') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('close')"
|
||||
class="w-10 h-10 rounded-[14px] bg-[#1A1A24] flex items-center justify-center hover:bg-[#222230] transition-colors border border-white/5"
|
||||
>
|
||||
<X :size="18" :stroke-width="2" class="text-zinc-400" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="px-5 pb-4">
|
||||
<div class="flex items-center gap-2 p-1.5 bg-[#16161F] rounded-[16px] border border-white/[0.06]">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
@click="activeTab = tab.id"
|
||||
:disabled="isLoading"
|
||||
class="relative flex-1 px-3 py-2.5 rounded-[12px] transition-all disabled:opacity-50"
|
||||
>
|
||||
<div
|
||||
v-if="activeTab === tab.id"
|
||||
v-motion="'activeTransactionTab'"
|
||||
:initial="false"
|
||||
class="absolute inset-0 bg-gradient-to-br from-purple-500/15 to-blue-500/15 rounded-[12px] border border-purple-500/20"
|
||||
/>
|
||||
<div class="relative z-10 flex items-center justify-center gap-2">
|
||||
<component
|
||||
:is="tab.icon"
|
||||
:size="16"
|
||||
:stroke-width="2"
|
||||
:class="activeTab === tab.id ? 'text-purple-400' : 'text-zinc-500'"
|
||||
/>
|
||||
<span
|
||||
:class="[
|
||||
'text-[13px] font-semibold',
|
||||
activeTab === tab.id ? 'text-purple-400' : 'text-zinc-500',
|
||||
]"
|
||||
>
|
||||
{{ t(tab.label) }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="px-5 mb-4">
|
||||
<div class="p-3.5 rounded-[14px] bg-rose-500/10 border border-rose-500/20 text-rose-400 text-[13px] font-medium">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="flex-1 overflow-y-auto px-5 pb-6">
|
||||
<Transition mode="out-in" name="fade">
|
||||
<div v-if="activeTab === 'manual'" key="manual" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.type') }} <span class="text-rose-400">*</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="handleTypeSelect('expense')"
|
||||
:disabled="isLoading"
|
||||
:class="[
|
||||
'p-4 rounded-[14px] border transition-all',
|
||||
transactionType === 'expense'
|
||||
? 'bg-rose-500/10 border-rose-500/30'
|
||||
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
|
||||
]"
|
||||
>
|
||||
<ArrowDownCircle
|
||||
:size="20"
|
||||
:stroke-width="2"
|
||||
:class="transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-500'"
|
||||
class="mb-2 mx-auto"
|
||||
/>
|
||||
<p
|
||||
:class="[
|
||||
'text-[13px] font-semibold',
|
||||
transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-400',
|
||||
]"
|
||||
>
|
||||
{{ t('finance.add.type.expense') }}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleTypeSelect('income')"
|
||||
:disabled="isLoading"
|
||||
:class="[
|
||||
'p-4 rounded-[14px] border transition-all',
|
||||
transactionType === 'income'
|
||||
? 'bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
|
||||
]"
|
||||
>
|
||||
<ArrowUpCircle
|
||||
:size="20"
|
||||
:stroke-width="2"
|
||||
:class="transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-500'"
|
||||
class="mb-2 mx-auto"
|
||||
/>
|
||||
<p
|
||||
:class="[
|
||||
'text-[13px] font-semibold',
|
||||
transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-400',
|
||||
]"
|
||||
>
|
||||
{{ t('finance.add.type.income') }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.amount') }} <span class="text-rose-400">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<DollarSign :size="20" :stroke-width="2" class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
v-model="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
:disabled="isLoading"
|
||||
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white placeholder:text-zinc-600 focus:border-purple-500/30 focus:outline-none transition-colors disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.category') }} <span class="text-rose-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedCategory"
|
||||
:disabled="isLoading"
|
||||
class="w-full px-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white focus:border-purple-500/30 focus:outline-none transition-colors appearance-none disabled:opacity-50"
|
||||
>
|
||||
<option value="" class="bg-[#16161F]">{{ t('finance.add.category.select') }}</option>
|
||||
<option v-for="category in availableCategories" :key="category.id" :value="category.id" class="bg-[#16161F]">
|
||||
{{ t(category.label) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.datetime') }} <span class="text-rose-400">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Calendar :size="20" :stroke-width="2" class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
v-model="datetime"
|
||||
type="datetime-local"
|
||||
:disabled="isLoading"
|
||||
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white focus:border-purple-500/30 focus:outline-none transition-colors disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.description') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<FileText :size="20" :stroke-width="2" class="absolute left-4 top-4 text-zinc-500" />
|
||||
<textarea
|
||||
v-model="description"
|
||||
:placeholder="t('finance.add.description.placeholder')"
|
||||
rows="3"
|
||||
:disabled="isLoading"
|
||||
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white placeholder:text-zinc-600 focus:border-purple-500/30 focus:outline-none transition-colors resize-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'receipt'" key="receipt" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.receiptNumber') }} <span class="text-rose-400">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Receipt :size="20" :stroke-width="2" class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
v-model="receiptNumber"
|
||||
type="text"
|
||||
:placeholder="t('finance.add.receiptNumber.placeholder')"
|
||||
:disabled="isLoading"
|
||||
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white placeholder:text-zinc-600 focus:border-purple-500/30 focus:outline-none transition-colors disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.receiptDate') }} <span class="text-rose-400">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Calendar :size="20" :stroke-width="2" class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
v-model="receiptDate"
|
||||
type="date"
|
||||
:disabled="isLoading"
|
||||
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white focus:border-purple-500/30 focus:outline-none transition-colors disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.type') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="handleTypeSelect('expense')"
|
||||
:disabled="isLoading"
|
||||
:class="[
|
||||
'p-3 rounded-[14px] border transition-all',
|
||||
transactionType === 'expense'
|
||||
? 'bg-rose-500/10 border-rose-500/30'
|
||||
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
|
||||
]"
|
||||
>
|
||||
<ArrowDownCircle
|
||||
:size="16"
|
||||
:stroke-width="2"
|
||||
:class="transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-500'"
|
||||
class="mb-1 mx-auto"
|
||||
/>
|
||||
<p
|
||||
:class="[
|
||||
'text-[12px] font-semibold',
|
||||
transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-400',
|
||||
]"
|
||||
>
|
||||
{{ t('finance.add.type.expense') }}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleTypeSelect('income')"
|
||||
:disabled="isLoading"
|
||||
:class="[
|
||||
'p-3 rounded-[14px] border transition-all',
|
||||
transactionType === 'income'
|
||||
? 'bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
|
||||
]"
|
||||
>
|
||||
<ArrowUpCircle
|
||||
:size="16"
|
||||
:stroke-width="2"
|
||||
:class="transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-500'"
|
||||
class="mb-1 mx-auto"
|
||||
/>
|
||||
<p
|
||||
:class="[
|
||||
'text-[12px] font-semibold',
|
||||
transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-400',
|
||||
]"
|
||||
>
|
||||
{{ t('finance.add.type.income') }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.category') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedCategory"
|
||||
:disabled="isLoading"
|
||||
class="w-full px-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white focus:border-purple-500/30 focus:outline-none transition-colors appearance-none disabled:opacity-50"
|
||||
>
|
||||
<option value="" class="bg-[#16161F]">{{ t('finance.add.category.select') }}</option>
|
||||
<option v-for="category in availableCategories" :key="category.id" :value="category.id" class="bg-[#16161F]">
|
||||
{{ t(category.label) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.description') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<FileText :size="20" :stroke-width="2" class="absolute left-4 top-4 text-zinc-500" />
|
||||
<textarea
|
||||
v-model="description"
|
||||
:placeholder="t('finance.add.description.placeholder')"
|
||||
rows="3"
|
||||
:disabled="isLoading"
|
||||
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white placeholder:text-zinc-600 focus:border-purple-500/30 focus:outline-none transition-colors resize-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else key="photo" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.photo.label') }} <span class="text-rose-400">*</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handlePhotoSelect"
|
||||
:disabled="isLoading"
|
||||
class="hidden"
|
||||
/>
|
||||
<div class="w-full aspect-[4/3] rounded-[16px] bg-[#16161F] border-2 border-dashed border-white/[0.1] hover:border-purple-500/30 transition-colors cursor-pointer flex flex-col items-center justify-center gap-3 relative overflow-hidden">
|
||||
<div v-if="selectedPhoto" class="absolute inset-0 flex items-center justify-center bg-[#16161F]">
|
||||
<div class="text-center p-4">
|
||||
<Camera :size="32" :stroke-width="2" class="text-purple-400 mx-auto mb-2" />
|
||||
<p class="text-white text-[13px] font-medium mb-1">{{ selectedPhoto.name }}</p>
|
||||
<p class="text-zinc-500 text-[11px]">{{ t('finance.add.photo.change') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<Camera :size="40" :stroke-width="2" class="text-zinc-600" />
|
||||
<div class="text-center">
|
||||
<p class="text-zinc-400 text-[13px] font-medium mb-1">{{ t('finance.add.photo.upload') }}</p>
|
||||
<p class="text-zinc-600 text-[11px]">{{ t('finance.add.photo.select') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.type') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="handleTypeSelect('expense')"
|
||||
:disabled="isLoading"
|
||||
:class="[
|
||||
'p-3 rounded-[14px] border transition-all',
|
||||
transactionType === 'expense'
|
||||
? 'bg-rose-500/10 border-rose-500/30'
|
||||
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
|
||||
]"
|
||||
>
|
||||
<ArrowDownCircle
|
||||
:size="16"
|
||||
:stroke-width="2"
|
||||
:class="transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-500'"
|
||||
class="mb-1 mx-auto"
|
||||
/>
|
||||
<p
|
||||
:class="[
|
||||
'text-[12px] font-semibold',
|
||||
transactionType === 'expense' ? 'text-rose-400' : 'text-zinc-400',
|
||||
]"
|
||||
>
|
||||
{{ t('finance.add.type.expense') }}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleTypeSelect('income')"
|
||||
:disabled="isLoading"
|
||||
:class="[
|
||||
'p-3 rounded-[14px] border transition-all',
|
||||
transactionType === 'income'
|
||||
? 'bg-emerald-500/10 border-emerald-500/30'
|
||||
: 'bg-[#16161F] border-white/[0.06] hover:border-white/[0.1]',
|
||||
]"
|
||||
>
|
||||
<ArrowUpCircle
|
||||
:size="16"
|
||||
:stroke-width="2"
|
||||
:class="transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-500'"
|
||||
class="mb-1 mx-auto"
|
||||
/>
|
||||
<p
|
||||
:class="[
|
||||
'text-[12px] font-semibold',
|
||||
transactionType === 'income' ? 'text-emerald-400' : 'text-zinc-400',
|
||||
]"
|
||||
>
|
||||
{{ t('finance.add.type.income') }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.category') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedCategory"
|
||||
:disabled="isLoading"
|
||||
class="w-full px-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white focus:border-purple-500/30 focus:outline-none transition-colors appearance-none disabled:opacity-50"
|
||||
>
|
||||
<option value="" class="bg-[#16161F]">{{ t('finance.add.category.select') }}</option>
|
||||
<option v-for="category in availableCategories" :key="category.id" :value="category.id" class="bg-[#16161F]">
|
||||
{{ t(category.label) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-zinc-400 text-[12px] font-medium mb-2 block">
|
||||
{{ t('finance.add.description') }} <span class="text-zinc-600 text-[11px]">{{ t('finance.add.optional') }}</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<FileText :size="20" :stroke-width="2" class="absolute left-4 top-4 text-zinc-500" />
|
||||
<textarea
|
||||
v-model="description"
|
||||
:placeholder="t('finance.add.description.placeholder')"
|
||||
rows="3"
|
||||
:disabled="isLoading"
|
||||
class="w-full pl-12 pr-4 py-3.5 rounded-[14px] bg-[#16161F] border border-white/[0.06] text-white placeholder:text-zinc-600 focus:border-purple-500/30 focus:outline-none transition-colors resize-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</main>
|
||||
|
||||
<div class="px-5 py-4 border-t border-white/[0.06]">
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('close')"
|
||||
:disabled="isLoading"
|
||||
class="flex-1 py-3.5 rounded-[14px] bg-[#16161F] hover:bg-[#1A1A24] border border-white/[0.06] text-zinc-400 text-[14px] font-semibold transition-all disabled:opacity-50"
|
||||
>
|
||||
{{ t('finance.add.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleSubmit"
|
||||
:disabled="isLoading"
|
||||
class="flex-1 py-3.5 rounded-[14px] bg-gradient-to-br from-purple-500 to-blue-600 hover:shadow-lg hover:shadow-purple-500/30 text-white text-[14px] font-semibold transition-all active:scale-95 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Loader2 v-if="isLoading" class="h-4 w-4 animate-spin" />
|
||||
{{ isLoading ? t('finance.add.submitting') : t('finance.add.submit') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
X,
|
||||
Camera,
|
||||
Receipt,
|
||||
Edit3,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
FileText,
|
||||
ArrowDownCircle,
|
||||
ArrowUpCircle,
|
||||
Loader2,
|
||||
} from 'lucide-vue-next'
|
||||
import { createTransaction, createTransactionFromPhoto } from '@/api/transactions'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
type TabType = 'manual' | 'receipt' | 'photo'
|
||||
type TransactionType = 'income' | 'expense'
|
||||
type OptionalTransactionType = TransactionType | ''
|
||||
|
||||
interface CategoryOption {
|
||||
id: string
|
||||
label: string
|
||||
types: TransactionType[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
familyId?: number
|
||||
userId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref<TabType>('manual')
|
||||
const transactionType = ref<OptionalTransactionType>('expense')
|
||||
const selectedCategory = ref('')
|
||||
const amount = ref('')
|
||||
const datetime = ref('')
|
||||
const description = ref('')
|
||||
const receiptNumber = ref('')
|
||||
const receiptDate = ref('')
|
||||
const selectedPhoto = ref<File | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'manual' as TabType, label: 'finance.add.tab.manual', icon: Edit3 },
|
||||
{ id: 'receipt' as TabType, label: 'finance.add.tab.receipt', icon: Receipt },
|
||||
{ id: 'photo' as TabType, label: 'finance.add.tab.photo', icon: Camera },
|
||||
]
|
||||
|
||||
const categories: CategoryOption[] = [
|
||||
{ id: 'groceries', label: 'finance.category.groceries', types: ['expense'] },
|
||||
{ id: 'shopping', label: 'finance.categories.shopping', types: ['expense'] },
|
||||
{ id: 'transport', label: 'finance.categories.transport', types: ['expense'] },
|
||||
{ id: 'housing', label: 'finance.categories.housing', types: ['expense'] },
|
||||
{ id: 'entertainment', label: 'finance.categories.entertainment', types: ['expense'] },
|
||||
{ id: 'bills_utilities', label: 'finance.categories.billsUtilities', types: ['expense'] },
|
||||
{ id: 'healthcare', label: 'finance.categories.healthcare', types: ['expense'] },
|
||||
{ id: 'other', label: 'finance.categories.others', types: ['expense'] },
|
||||
{ id: 'income', label: 'finance.category.income', types: ['income'] },
|
||||
{ id: 'work', label: 'finance.categories.work', types: ['income'] },
|
||||
{ id: 'gifts_donations', label: 'finance.categories.giftsDonations', types: ['income'] },
|
||||
]
|
||||
|
||||
const availableCategories = computed(() => {
|
||||
if (!transactionType.value) {
|
||||
return categories
|
||||
}
|
||||
|
||||
return categories.filter((category) => category.types.includes(transactionType.value as TransactionType))
|
||||
})
|
||||
|
||||
const resolvedFamilyId = computed(() => {
|
||||
if (typeof props.familyId === 'number' && Number.isFinite(props.familyId) && props.familyId > 0) {
|
||||
return props.familyId
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const now = new Date()
|
||||
const offset = now.getTimezoneOffset() * 60000
|
||||
datetime.value = new Date(now.getTime() - offset).toISOString().slice(0, 16)
|
||||
receiptDate.value = new Date().toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
errorMessage.value = ''
|
||||
|
||||
if (tab === 'manual' && !transactionType.value) {
|
||||
transactionType.value = 'expense'
|
||||
}
|
||||
})
|
||||
|
||||
watch(transactionType, () => {
|
||||
if (!availableCategories.value.some((category) => category.id === selectedCategory.value)) {
|
||||
selectedCategory.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
const handlePhotoSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
selectedPhoto.value = target.files?.[0] ?? null
|
||||
}
|
||||
|
||||
const handleTypeSelect = (type: TransactionType) => {
|
||||
if (activeTab.value === 'manual') {
|
||||
transactionType.value = type
|
||||
return
|
||||
}
|
||||
|
||||
transactionType.value = transactionType.value === type ? '' : type
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!resolvedFamilyId.value) {
|
||||
errorMessage.value = t('finance.add.error.missingFamily')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
if (activeTab.value === 'manual') {
|
||||
const parsedAmount = Number.parseFloat(amount.value)
|
||||
|
||||
if (!transactionType.value || !selectedCategory.value || !datetime.value || !Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
||||
throw new Error(t('finance.add.error.missingFields'))
|
||||
}
|
||||
|
||||
await createTransaction({
|
||||
family_id: resolvedFamilyId.value,
|
||||
type: transactionType.value,
|
||||
category: selectedCategory.value,
|
||||
amount: parsedAmount,
|
||||
datetime: new Date(datetime.value).toISOString(),
|
||||
description: description.value.trim() || undefined,
|
||||
})
|
||||
} else if (activeTab.value === 'receipt') {
|
||||
if (!receiptNumber.value.trim() || !receiptDate.value) {
|
||||
throw new Error(t('finance.add.error.missingFields'))
|
||||
}
|
||||
|
||||
await createTransaction({
|
||||
family_id: resolvedFamilyId.value,
|
||||
receipt_number: receiptNumber.value.trim(),
|
||||
receipt_date: receiptDate.value,
|
||||
type: transactionType.value || undefined,
|
||||
category: selectedCategory.value || undefined,
|
||||
description: description.value.trim() || undefined,
|
||||
})
|
||||
} else {
|
||||
if (!selectedPhoto.value) {
|
||||
throw new Error(t('finance.add.error.missingPhoto'))
|
||||
}
|
||||
|
||||
await createTransactionFromPhoto({
|
||||
photo: selectedPhoto.value,
|
||||
family_id: resolvedFamilyId.value,
|
||||
type: transactionType.value || undefined,
|
||||
category: selectedCategory.value || undefined,
|
||||
description: description.value.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
emit('success')
|
||||
emit('close')
|
||||
} catch (error: unknown) {
|
||||
errorMessage.value = error instanceof Error && error.message
|
||||
? error.message
|
||||
: t('finance.add.error.generic')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Utensils,
|
||||
Zap,
|
||||
} from 'lucide-vue-next';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { Eye, EyeOff, TrendingUp } from 'lucide-vue-next';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
const isVisible = ref(true);
|
||||
const chartData = [20000, 21500, 20800, 23200, 22500, 24850];
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { Bell, Plus, Settings, Wallet as WalletIcon } from 'lucide-vue-next';
|
||||
import { Plus, Wallet as WalletIcon } from 'lucide-vue-next';
|
||||
import FinanceBalanceCard from './FinanceBalanceCard.vue';
|
||||
import TransactionsList from './TransactionsList.vue';
|
||||
import AnalyticsView from './AnalyticsView.vue';
|
||||
import CategoriesView from './CategoriesView.vue';
|
||||
import Navigation from './Navigation.vue';
|
||||
import { useI18n } from '../i18n';
|
||||
import HeaderWidget from './HeaderWidget.vue';
|
||||
import { useI18n } from '@/i18n';
|
||||
import AddTransactionScreen from "@/components/AddTransactionScreen.vue";
|
||||
import TransactionDetailScreen from "@/components/TransactionDetailScreen.vue";
|
||||
import type { Transaction } from '@/types/transaction'
|
||||
|
||||
type Tab = 'transactions' | 'analytics' | 'categories';
|
||||
|
||||
@@ -16,11 +20,19 @@ const emit = defineEmits<{
|
||||
|
||||
defineProps<{
|
||||
familyId?: number;
|
||||
userId?: number;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const activeTab = ref<Tab>('transactions');
|
||||
const showAddTransaction = ref(false);
|
||||
const selectedTransaction = ref<Transaction | null>(null);
|
||||
const transactionsListKey = ref(0);
|
||||
|
||||
const handleTransactionSuccess = () => {
|
||||
transactionsListKey.value++;
|
||||
};
|
||||
|
||||
const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
|
||||
{ id: 'transactions', label: t('finance.tab.transactions') },
|
||||
@@ -30,31 +42,30 @@ const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#0A0A0F] dark">
|
||||
<!-- Transaction Detail Screen -->
|
||||
<TransactionDetailScreen
|
||||
v-if="selectedTransaction"
|
||||
:transaction="selectedTransaction"
|
||||
@close="selectedTransaction = null"
|
||||
/>
|
||||
|
||||
<!-- Add Transaction Screen -->
|
||||
<AddTransactionScreen
|
||||
v-else-if="showAddTransaction"
|
||||
:family-id="familyId"
|
||||
:user-id="userId"
|
||||
@close="showAddTransaction = false"
|
||||
@success="handleTransactionSuccess"
|
||||
/>
|
||||
|
||||
<div v-else class="min-h-screen bg-[#0A0A0F] dark">
|
||||
<div class="mx-auto flex min-h-screen max-w-md flex-col relative">
|
||||
<header class="flex items-center justify-between px-5 pt-6 pb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-11 w-11 items-center justify-center rounded-[16px] bg-gradient-to-br from-purple-500 to-blue-600 shadow-lg shadow-purple-500/20">
|
||||
<WalletIcon class="h-5 w-5 text-white" :stroke-width="2.5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0.5 text-[11px] font-normal text-zinc-500">{{ t('finance.header.eyebrow') }}</p>
|
||||
<h1 class="text-[17px] font-semibold tracking-tight text-white">{{ t('finance.header.title') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button type="button" class="flex h-10 w-10 items-center justify-center rounded-[14px] border border-white/5 bg-[#1A1A24] transition-colors hover:bg-[#222230]">
|
||||
<Bell class="h-[18px] w-[18px] text-zinc-400" :stroke-width="2" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-[14px] border border-white/5 bg-[#1A1A24] transition-colors hover:bg-[#222230]"
|
||||
@click="emit('navigate', 'settings')"
|
||||
>
|
||||
<Settings class="h-[18px] w-[18px] text-zinc-400" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<HeaderWidget
|
||||
:icon="WalletIcon"
|
||||
:eyebrow="t('finance.header.eyebrow')"
|
||||
:title="t('finance.header.title')"
|
||||
@navigate="emit('navigate', $event)"
|
||||
/>
|
||||
|
||||
<main class="flex-1 overflow-y-auto px-5 pb-24">
|
||||
<div class="mb-5">
|
||||
@@ -81,13 +92,14 @@ const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransactionsList v-if="activeTab === 'transactions'" :family-id="familyId" />
|
||||
<TransactionsList v-if="activeTab === 'transactions'" :family-id="familyId" :key="transactionsListKey" />
|
||||
<AnalyticsView v-else-if="activeTab === 'analytics'" />
|
||||
<CategoriesView v-else />
|
||||
</main>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="showAddTransaction = true"
|
||||
class="group fixed bottom-24 right-6 z-10 flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-purple-500 to-blue-600 shadow-[0_8px_32px_rgba(139,92,246,0.4)] transition-all hover:shadow-[0_12px_40px_rgba(139,92,246,0.6)] active:scale-95"
|
||||
>
|
||||
<Plus class="h-6 w-6 text-white transition-transform duration-300 group-hover:rotate-90" :stroke-width="2.5" />
|
||||
|
||||
@@ -1,64 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { Bell, Settings, User } from 'lucide-vue-next';
|
||||
import { useI18n } from '@/i18n';
|
||||
import type { Component } from 'vue';
|
||||
import { Bell, Settings } from 'lucide-vue-next';
|
||||
|
||||
defineProps<{
|
||||
icon: Component;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [screen: string];
|
||||
}>();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
familyName?: string;
|
||||
}>(), {
|
||||
familyName: '',
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentHour = ref(new Date().getHours());
|
||||
let timerId: number | undefined;
|
||||
|
||||
const greeting = computed(() => {
|
||||
if (currentHour.value < 5) {
|
||||
return t('header.greeting.night');
|
||||
}
|
||||
|
||||
if (currentHour.value < 12) {
|
||||
return t('header.greeting.morning');
|
||||
}
|
||||
|
||||
if (currentHour.value < 18) {
|
||||
return t('header.greeting.afternoon');
|
||||
}
|
||||
|
||||
return t('header.greeting.evening');
|
||||
});
|
||||
|
||||
function updateCurrentHour() {
|
||||
currentHour.value = new Date().getHours();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateCurrentHour();
|
||||
timerId = window.setInterval(updateCurrentHour, 60_000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timerId !== undefined) {
|
||||
window.clearInterval(timerId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex items-center justify-between px-5 pt-6 pb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-11 w-11 items-center justify-center rounded-[16px] bg-gradient-to-br from-purple-500 to-blue-600 shadow-lg shadow-purple-500/20">
|
||||
<User class="h-5 w-5 text-white" :stroke-width="2.5" />
|
||||
<component :is="icon" class="h-5 w-5 text-white" :stroke-width="2.5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0.5 text-[11px] font-normal text-zinc-500">{{ greeting }}</p>
|
||||
<h1 class="text-[17px] font-semibold tracking-tight text-white">{{ props.familyName || t('header.familyName') }}</h1>
|
||||
<p class="mb-0.5 text-[11px] font-normal text-zinc-500">{{ eyebrow }}</p>
|
||||
<h1 class="text-[17px] font-semibold tracking-tight text-white">{{ title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -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>
|
||||
@@ -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,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {Calendar, Heart, Home, Sparkles, Wallet} from 'lucide-vue-next';
|
||||
import { computed, type Component } from 'vue';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useI18n } from '@/i18n';
|
||||
|
||||
interface NavItem {
|
||||
icon: Component;
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#0A0A0F] dark">
|
||||
<div class="mx-auto max-w-md min-h-screen flex flex-col relative">
|
||||
<!-- Header -->
|
||||
<header
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: -20 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
class="flex items-center justify-between px-5 pt-6 pb-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'w-11 h-11 rounded-[16px] bg-gradient-to-br flex items-center justify-center shadow-lg',
|
||||
transaction.type === 'income'
|
||||
? 'from-emerald-500 to-green-600 shadow-emerald-500/20'
|
||||
: 'from-rose-500 to-red-600 shadow-rose-500/20'
|
||||
]"
|
||||
>
|
||||
<TrendingUp v-if="transaction.type === 'income'" :size="20" :stroke-width="2.5" class="text-white" />
|
||||
<TrendingDown v-else :size="20" :stroke-width="2.5" class="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] text-zinc-500 font-normal mb-0.5">Transaction details</p>
|
||||
<h1 class="text-[17px] text-white font-semibold tracking-tight">{{ transaction.title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="w-10 h-10 rounded-[14px] bg-[#1A1A24] flex items-center justify-center hover:bg-[#222230] transition-colors border border-white/5"
|
||||
>
|
||||
<X :size="18" :stroke-width="2" class="text-zinc-400" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-y-auto px-5 pb-6">
|
||||
<div class="space-y-4">
|
||||
<!-- Transaction Summary Card -->
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { delay: 100 } }"
|
||||
:class="[
|
||||
'rounded-[20px] bg-gradient-to-br p-5 border relative overflow-hidden',
|
||||
transaction.type === 'income'
|
||||
? 'from-emerald-600/20 via-emerald-500/10 to-green-600/20 border-emerald-500/20'
|
||||
: 'from-rose-600/20 via-rose-500/10 to-red-600/20 border-rose-500/20'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'absolute -top-10 -right-10 w-32 h-32 rounded-full blur-3xl opacity-10',
|
||||
transaction.type === 'income' ? 'bg-emerald-500' : 'bg-rose-500'
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
:class="[
|
||||
'absolute -bottom-8 -left-8 w-24 h-24 rounded-full blur-3xl opacity-10',
|
||||
transaction.type === 'income' ? 'bg-green-500' : 'bg-red-500'
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="relative z-10">
|
||||
<!-- Amount -->
|
||||
<div class="text-center mb-5">
|
||||
<p
|
||||
:class="[
|
||||
'text-[13px] font-medium mb-2',
|
||||
transaction.type === 'income' ? 'text-emerald-300' : 'text-rose-300'
|
||||
]"
|
||||
>
|
||||
{{ transaction.type === 'income' ? 'Income' : 'Expense' }}
|
||||
</p>
|
||||
<h2 class="text-white text-[42px] font-bold tracking-tight">
|
||||
{{ transaction.type === 'income' ? '+' : '-' }}${{ transaction.amount.toFixed(2) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Info Grid -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-white/5 backdrop-blur-sm rounded-[14px] p-3 border border-white/10">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Calendar :size="14" :stroke-width="2" class="text-zinc-400" />
|
||||
<p class="text-zinc-500 text-[11px] font-medium">Date</p>
|
||||
</div>
|
||||
<p class="text-white text-[13px] font-semibold">{{ transaction.date }}</p>
|
||||
</div>
|
||||
<div class="bg-white/5 backdrop-blur-sm rounded-[14px] p-3 border border-white/10">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Clock :size="14" :stroke-width="2" class="text-zinc-400" />
|
||||
<p class="text-zinc-500 text-[11px] font-medium">Time</p>
|
||||
</div>
|
||||
<p class="text-white text-[13px] font-semibold">{{ transaction.time }}</p>
|
||||
</div>
|
||||
<div class="bg-white/5 backdrop-blur-sm rounded-[14px] p-3 border border-white/10 col-span-2">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Tag :size="14" :stroke-width="2" class="text-zinc-400" />
|
||||
<p class="text-zinc-500 text-[11px] font-medium">Category</p>
|
||||
</div>
|
||||
<p class="text-white text-[13px] font-semibold">{{ transaction.category }}</p>
|
||||
</div>
|
||||
<div v-if="transaction.description" class="bg-white/5 backdrop-blur-sm rounded-[14px] p-3 border border-white/10 col-span-2">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Receipt :size="14" :stroke-width="2" class="text-zinc-400" />
|
||||
<p class="text-zinc-500 text-[11px] font-medium">Description</p>
|
||||
</div>
|
||||
<p class="text-white text-[13px]">{{ transaction.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items List -->
|
||||
<div
|
||||
v-if="hasItems"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { delay: 200 } }"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<ShoppingBag :size="16" :stroke-width="2" class="text-purple-400" />
|
||||
<h2 class="text-white text-[15px] font-semibold">
|
||||
Items ({{ transaction.items?.length }})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="(item, index) in transaction.items"
|
||||
:key="item.id"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, x: -10 }"
|
||||
:enter="{ opacity: 1, x: 0, transition: { delay: 250 + index * 50 } }"
|
||||
class="rounded-[16px] bg-[#16161F] border border-white/[0.06] p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-white text-[14px] font-semibold mb-1">{{ item.name }}</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<Hash :size="12" :stroke-width="2" class="text-zinc-600" />
|
||||
<span class="text-zinc-500 text-[12px]">×{{ item.quantity }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-zinc-500 text-[12px]">${{ item.price.toFixed(2) }} each</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-white text-[15px] font-bold">
|
||||
${{ (item.price * item.quantity).toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="item.discount && item.discount > 0" class="pt-3 border-t border-white/[0.06]">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Percent :size="14" :stroke-width="2" class="text-emerald-400" />
|
||||
<span class="text-emerald-400 text-[12px] font-medium">Discount</span>
|
||||
</div>
|
||||
<span class="text-emerald-400 text-[12px] font-semibold">
|
||||
-${{ (item.discount * item.quantity).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { delay: 300 + (transaction.items?.length || 0) * 50 } }"
|
||||
class="mt-4 rounded-[16px] bg-[#16161F] border border-white/[0.06] p-4 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-zinc-500 text-[13px]">Subtotal</span>
|
||||
<span class="text-white text-[13px] font-semibold">${{ subtotal.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-if="totalDiscount > 0" class="flex items-center justify-between">
|
||||
<span class="text-emerald-400 text-[13px]">Total Discount</span>
|
||||
<span class="text-emerald-400 text-[13px] font-semibold">
|
||||
-${{ totalDiscount.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pt-2 border-t border-white/[0.06] flex items-center justify-between">
|
||||
<span class="text-white text-[14px] font-semibold">Total</span>
|
||||
<span class="text-white text-[16px] font-bold">
|
||||
${{ (subtotal - totalDiscount).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Items Message -->
|
||||
<div
|
||||
v-else
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 20 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { delay: 200 } }"
|
||||
class="rounded-[16px] bg-[#16161F] border border-white/[0.06] p-8 text-center"
|
||||
>
|
||||
<ShoppingBag :size="48" :stroke-width="1.5" class="text-zinc-700 mx-auto mb-3" />
|
||||
<p class="text-zinc-500 text-[14px]">No items in this transaction</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="px-5 py-4 border-t border-white/[0.06]">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="w-full py-3.5 rounded-[14px] bg-gradient-to-br from-purple-500 to-blue-600 hover:shadow-lg hover:shadow-purple-500/30 text-white text-[14px] font-semibold transition-all active:scale-95"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
X,
|
||||
Calendar,
|
||||
Clock,
|
||||
Tag,
|
||||
Receipt,
|
||||
ShoppingBag,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Percent,
|
||||
Hash,
|
||||
} from 'lucide-vue-next'
|
||||
import type { Transaction } from '../types/transaction'
|
||||
|
||||
interface Props {
|
||||
transaction: Transaction
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const hasItems = computed(() => props.transaction.items && props.transaction.items.length > 0)
|
||||
|
||||
const subtotal = computed(() =>
|
||||
props.transaction.items?.reduce((sum, item) => sum + (item.price * item.quantity), 0) || 0
|
||||
)
|
||||
|
||||
const totalDiscount = computed(() =>
|
||||
props.transaction.items?.reduce((sum, item) => sum + ((item.discount || 0) * item.quantity), 0) || 0
|
||||
)
|
||||
</script>
|
||||
@@ -113,11 +113,43 @@ const messages: Record<Locale, Messages> = {
|
||||
'finance.categories.entertainment': 'Entertainment',
|
||||
'finance.categories.coffee': 'Coffee',
|
||||
'finance.categories.billsUtilities': 'Bills & Utilities',
|
||||
'finance.categories.healthcare': 'Healthcare',
|
||||
'finance.categories.work': 'Work',
|
||||
'finance.categories.giftsDonations': 'Gifts & Donations',
|
||||
'finance.categories.others': 'Others',
|
||||
'finance.categories.of': 'of',
|
||||
|
||||
'finance.add.title': 'Transaction',
|
||||
'finance.add.eyebrow': 'Add new',
|
||||
'finance.add.tab.manual': 'Manual',
|
||||
'finance.add.tab.receipt': 'Receipt',
|
||||
'finance.add.tab.photo': 'Photo',
|
||||
'finance.add.type': 'Type',
|
||||
'finance.add.type.expense': 'Expense',
|
||||
'finance.add.type.income': 'Income',
|
||||
'finance.add.amount': 'Amount',
|
||||
'finance.add.category': 'Category',
|
||||
'finance.add.category.select': 'Select category',
|
||||
'finance.add.datetime': 'Date & Time',
|
||||
'finance.add.description': 'Description',
|
||||
'finance.add.description.placeholder': 'Add notes...',
|
||||
'finance.add.optional': '(optional)',
|
||||
'finance.add.receiptNumber': 'Receipt Number',
|
||||
'finance.add.receiptNumber.placeholder': 'Enter receipt number',
|
||||
'finance.add.receiptDate': 'Receipt Date',
|
||||
'finance.add.photo.label': 'Receipt Photo',
|
||||
'finance.add.photo.upload': 'Upload receipt photo',
|
||||
'finance.add.photo.select': 'Tap to select from gallery',
|
||||
'finance.add.photo.change': 'Tap to change',
|
||||
'finance.add.submit': 'Add Transaction',
|
||||
'finance.add.submitting': 'Processing...',
|
||||
'finance.add.cancel': 'Cancel',
|
||||
'finance.add.error.missingFields': 'Please fill all required fields',
|
||||
'finance.add.error.missingPhoto': 'Please select a photo',
|
||||
'finance.add.error.missingFamily': 'Family ID is missing',
|
||||
'finance.add.error.missingUser': 'User ID is missing',
|
||||
'finance.add.error.generic': 'Failed to add transaction',
|
||||
|
||||
'settings.header.eyebrow': 'Manage your family hub',
|
||||
'settings.header.title': 'Settings',
|
||||
'settings.profile.role': 'Family Admin',
|
||||
@@ -152,6 +184,9 @@ const messages: Record<Locale, Messages> = {
|
||||
'settings.module.active': 'Active',
|
||||
'settings.module.disabled': 'Disabled',
|
||||
'settings.signOut': 'Sign Out',
|
||||
|
||||
'calendar.header.eyebrow': 'Plan your week',
|
||||
'calendar.header.title': 'Calendar',
|
||||
},
|
||||
ru: {
|
||||
'language.english': 'Английский',
|
||||
@@ -259,11 +294,43 @@ const messages: Record<Locale, Messages> = {
|
||||
'finance.categories.entertainment': 'Развлечения',
|
||||
'finance.categories.coffee': 'Кофе',
|
||||
'finance.categories.billsUtilities': 'Счета и коммунальные',
|
||||
'finance.categories.healthcare': 'Здоровье',
|
||||
'finance.categories.work': 'Работа',
|
||||
'finance.categories.giftsDonations': 'Подарки и пожертвования',
|
||||
'finance.categories.others': 'Прочее',
|
||||
'finance.categories.of': 'из',
|
||||
|
||||
'finance.add.title': 'Транзакция',
|
||||
'finance.add.eyebrow': 'Добавить',
|
||||
'finance.add.tab.manual': 'Вручную',
|
||||
'finance.add.tab.receipt': 'По чеку',
|
||||
'finance.add.tab.photo': 'Фото',
|
||||
'finance.add.type': 'Тип',
|
||||
'finance.add.type.expense': 'Расход',
|
||||
'finance.add.type.income': 'Доход',
|
||||
'finance.add.amount': 'Сумма',
|
||||
'finance.add.category': 'Категория',
|
||||
'finance.add.category.select': 'Выберите категорию',
|
||||
'finance.add.datetime': 'Дата и время',
|
||||
'finance.add.description': 'Описание',
|
||||
'finance.add.description.placeholder': 'Добавьте примечания...',
|
||||
'finance.add.optional': '(опционально)',
|
||||
'finance.add.receiptNumber': 'Номер чека',
|
||||
'finance.add.receiptNumber.placeholder': 'Введите номер чека',
|
||||
'finance.add.receiptDate': 'Дата чека',
|
||||
'finance.add.photo.label': 'Фото чека',
|
||||
'finance.add.photo.upload': 'Загрузите фото чека',
|
||||
'finance.add.photo.select': 'Нажмите, чтобы выбрать из галереи',
|
||||
'finance.add.photo.change': 'Нажмите, чтобы изменить',
|
||||
'finance.add.submit': 'Добавить транзакцию',
|
||||
'finance.add.submitting': 'Обработка...',
|
||||
'finance.add.cancel': 'Отмена',
|
||||
'finance.add.error.missingFields': 'Пожалуйста, заполните все обязательные поля',
|
||||
'finance.add.error.missingPhoto': 'Пожалуйста, выберите фото',
|
||||
'finance.add.error.missingFamily': 'ID семьи отсутствует',
|
||||
'finance.add.error.missingUser': 'ID пользователя отсутствует',
|
||||
'finance.add.error.generic': 'Не удалось добавить транзакцию',
|
||||
|
||||
'settings.header.eyebrow': 'Управляйте семейным хабом',
|
||||
'settings.header.title': 'Настройки',
|
||||
'settings.profile.role': 'Администратор семьи',
|
||||
@@ -298,6 +365,9 @@ const messages: Record<Locale, Messages> = {
|
||||
'settings.module.active': 'Активен',
|
||||
'settings.module.disabled': 'Отключён',
|
||||
'settings.signOut': 'Выйти',
|
||||
|
||||
'calendar.header.eyebrow': 'Планируй свою неделю',
|
||||
'calendar.header.title': 'Календарь',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
Vendored
+1
@@ -2,6 +2,7 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_FAMILY_ID?: string
|
||||
readonly VITE_USER_ID?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -39,6 +39,7 @@ 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user