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