From 97d923142e41a118b22dadb1126fcc485a02adfd Mon Sep 17 00:00:00 2001 From: AlexBelyan Date: Sat, 30 May 2026 10:30:26 +0300 Subject: [PATCH] =?UTF-8?q?12=20=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=82=D1=80=D0=B0=D0=BD=D0=B7=D0=B0=D0=BA=D1=86=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B5,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=20=D1=81=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D1=8B=D0=B5=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/api/routers/errors.go | 62 +- backend/src/api/routers/transactions.go | 12 +- backend/src/api/routers/transactions_test.go | 127 ++++ backend/src/api/server.go | 2 +- backend/src/api/services/receipts.go | 2 +- backend/src/api/static.go | 20 +- backend/src/api/static_test.go | 45 ++ .../receiptProvider/receipt_provider.go | 7 +- backend/src/utils/date.go | 6 +- backend/src/utils/parser.go | 27 +- frontend/src/App.vue | 79 +- frontend/src/api/transactions.ts | 64 ++ .../src/components/AddTransactionScreen.vue | 672 ++++++++++++++++++ frontend/src/components/CalendarScreen.vue | 315 ++++++++ frontend/src/components/CategoriesView.vue | 2 +- .../src/components/FinanceBalanceCard.vue | 2 +- frontend/src/components/FinanceScreen.vue | 66 +- .../{Header.vue => HeaderWidget.vue} | 59 +- frontend/src/components/HomeScreen.vue | 84 +++ frontend/src/components/IntimacyScreen.vue | 310 ++++++++ frontend/src/components/Navigation.vue | 2 +- .../components/TransactionDetailScreen.vue | 259 +++++++ frontend/src/i18n.ts | 70 ++ frontend/src/types/transaction.ts | 27 + frontend/src/vite-env.d.ts | 1 + 25 files changed, 2178 insertions(+), 144 deletions(-) create mode 100644 backend/src/api/static_test.go create mode 100644 frontend/src/components/AddTransactionScreen.vue create mode 100644 frontend/src/components/CalendarScreen.vue rename frontend/src/components/{Header.vue => HeaderWidget.vue} (52%) create mode 100644 frontend/src/components/HomeScreen.vue create mode 100644 frontend/src/components/IntimacyScreen.vue create mode 100644 frontend/src/components/TransactionDetailScreen.vue create mode 100644 frontend/src/types/transaction.ts diff --git a/backend/src/api/routers/errors.go b/backend/src/api/routers/errors.go index b29d43d..ec3fd93 100644 --- a/backend/src/api/routers/errors.go +++ b/backend/src/api/routers/errors.go @@ -5,9 +5,11 @@ import ( "FamilyHub/src/api/requests" "FamilyHub/src/api/services" receiptServiceIntegration "FamilyHub/src/integrations/receiptProvider" + "context" "database/sql" "errors" "log" + "net" "net/http" "runtime/debug" @@ -38,27 +40,7 @@ func logInternalError(c *gin.Context, scope string, err error) { } func handleReceiptError(c *gin.Context, err error) { - var externalErr *receiptServiceIntegration.ExternalServiceError - - switch { - case errors.Is(err, receiptServiceIntegration.ErrReceiptNotFound): - c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) - case errors.As(err, &externalErr): - log.Printf( - "receipt external service error: method=%s path=%s upstream_status=%d upstream_body=%q", - c.Request.Method, - c.Request.URL.Path, - externalErr.StatusCode, - externalErr.Body, - ) - logError(c, "receipt external service", err) - switch externalErr.StatusCode { - case http.StatusForbidden, http.StatusTooManyRequests: - c.JSON(http.StatusServiceUnavailable, dto.ErrorResponse{Message: "receipt service temporarily unavailable"}) - default: - c.JSON(http.StatusBadGateway, dto.ErrorResponse{Message: "receipt service error"}) - } - default: + if !handleReceiptProviderError(c, err) { logInternalError(c, "receipt request", err) c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) } @@ -96,3 +78,41 @@ func handleUserError(c *gin.Context, err error) { c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) } } + +func handleReceiptProviderError(c *gin.Context, err error) bool { + var externalErr *receiptServiceIntegration.ExternalServiceError + + switch { + case errors.Is(err, services.ErrReceiptNotFound), + errors.Is(err, receiptServiceIntegration.ErrReceiptNotFound): + logError(c, "receipt request", err) + c.JSON(http.StatusUnprocessableEntity, dto.ErrorResponse{Message: err.Error()}) + return true + case isTimeoutError(err): + logError(c, "receipt request", err) + c.JSON(http.StatusGatewayTimeout, dto.ErrorResponse{Message: "receipt service timeout"}) + return true + case errors.As(err, &externalErr): + log.Printf( + "receipt external service error: method=%s path=%s upstream_status=%d upstream_body=%q", + c.Request.Method, + c.Request.URL.Path, + externalErr.StatusCode, + externalErr.Body, + ) + logError(c, "receipt external service", err) + c.JSON(http.StatusServiceUnavailable, dto.ErrorResponse{Message: "receipt service unavailable"}) + return true + default: + return false + } +} + +func isTimeoutError(err error) bool { + if errors.Is(err, context.DeadlineExceeded) { + return true + } + + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} diff --git a/backend/src/api/routers/transactions.go b/backend/src/api/routers/transactions.go index 47e8b41..905b33a 100644 --- a/backend/src/api/routers/transactions.go +++ b/backend/src/api/routers/transactions.go @@ -65,12 +65,14 @@ func (router *TransactionsRouter) Create(c *gin.Context) { var req dto.CreateTransactionRequest if err := c.ShouldBindJSON(&req); err != nil { + logError(c, "transaction request validation", err) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } input, err := requests.BuildCreateTransactionInput(req) if err != nil { + logError(c, "transaction request validation", err) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } @@ -87,6 +89,7 @@ func (router *TransactionsRouter) Create(c *gin.Context) { func (router *TransactionsRouter) createFromMultipart(c *gin.Context) { fileHeader, err := c.FormFile("photo") if err != nil { + logError(c, "transaction request validation", err) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"}) return } @@ -108,11 +111,13 @@ func (router *TransactionsRouter) createFromMultipart(c *gin.Context) { familyID, err := parseOptionalInt64Form(c, "family_id") if err != nil { + logError(c, "transaction request validation", err) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } createdBy, err := parseOptionalInt64Form(c, "created_by") if err != nil { + logError(c, "transaction request validation", err) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } @@ -126,6 +131,7 @@ func (router *TransactionsRouter) createFromMultipart(c *gin.Context) { Description: parseOptionalStringForm(c, "description"), }) if err != nil { + logError(c, "transaction request validation", err) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) return } @@ -335,6 +341,10 @@ func (router *TransactionsRouter) Delete(c *gin.Context) { } func handleTransactionError(c *gin.Context, err error) { + if handleReceiptProviderError(c, err) { + return + } + switch { case errors.Is(err, services.ErrTransactionNotFound): c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) @@ -352,8 +362,6 @@ func handleTransactionError(c *gin.Context, err error) { errors.Is(err, services.ErrOCRNotConfigured), errors.Is(err, services.ErrReceiptTransactionNotCreated): c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: err.Error()}) - case errors.Is(err, services.ErrReceiptNotFound): - c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) default: logInternalError(c, "transaction request", err) c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"}) diff --git a/backend/src/api/routers/transactions_test.go b/backend/src/api/routers/transactions_test.go index 4870a6d..fff31d4 100644 --- a/backend/src/api/routers/transactions_test.go +++ b/backend/src/api/routers/transactions_test.go @@ -5,6 +5,7 @@ import ( "FamilyHub/src/api/requests" "FamilyHub/src/api/services" "FamilyHub/src/domain" + receiptProvider "FamilyHub/src/integrations/receiptProvider" "bytes" "context" "errors" @@ -199,6 +200,54 @@ func TestTransactionsRouter_Create(t *testing.T) { assert.Contains(t, w.Body.String(), `"id":21`) }) + t.Run("creates transaction from receipt with iso date", func(t *testing.T) { + r := gin.New() + apiV1 := r.Group("/api/v1") + + receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + assert.Equal(t, validNumber, req.Number) + assert.Equal(t, "2025-11-09", req.Date) + require.NotNil(t, req.FamilyID) + require.NotNil(t, req.CreatedBy) + assert.Equal(t, int64(1), *req.FamilyID) + assert.Equal(t, int64(1), *req.CreatedBy) + return &domain.Receipt{ID: 9, ReceiptNumber: validNumber, IssuedAt: now, TransactionID: ptrInt64(23)}, nil + }} + service := &transactionServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) { + assert.Equal(t, int64(23), id) + return &domain.Transaction{ + ID: 23, + FamilyID: 1, + Type: "expense", + DateTime: now, + Category: "receipt", + Amount: 89.4, + CreatedBy: 1, + CreatedAt: now, + ReceiptID: ptrInt64(9), + }, nil + }} + + creationService := services.NewTransactionCreationService(service, receiptSvc, nil) + router := NewTransactionsRouter(service, creationService) + router.RegisterRoutes(apiV1) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{ + "family_id":1, + "created_by":1, + "receipt_number":"`+validNumber+`", + "receipt_date":"2025-11-09", + "type":"expense" + }`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusCreated, w.Code) + assert.Contains(t, w.Body.String(), `"id":23`) + }) + t.Run("creates transaction from photo upload", func(t *testing.T) { r := gin.New() apiV1 := r.Group("/api/v1") @@ -290,6 +339,84 @@ func TestTransactionsRouter_Create(t *testing.T) { require.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "family_id and created_by are required for receipt transaction") }) + + t.Run("returns 422 when receipt is not found in photo flow", func(t *testing.T) { + r := gin.New() + apiV1 := r.Group("/api/v1") + + ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) { + return "21.01.2026 " + validNumber, nil + }} + receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + return nil, receiptProvider.ErrReceiptNotFound + }} + creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc) + router := NewTransactionsRouter(&transactionServiceMock{}, creationService) + router.RegisterRoutes(apiV1) + + req := newMultipartRequest(t, map[string]string{ + "family_id": "1", + "created_by": "2", + }) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusUnprocessableEntity, w.Code) + assert.Contains(t, w.Body.String(), "receipt not found") + }) + + t.Run("returns 503 when receipt provider is unavailable in photo flow", func(t *testing.T) { + r := gin.New() + apiV1 := r.Group("/api/v1") + + ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) { + return "21.01.2026 " + validNumber, nil + }} + receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + return nil, &receiptProvider.ExternalServiceError{StatusCode: http.StatusBadGateway, Body: "upstream failed"} + }} + creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc) + router := NewTransactionsRouter(&transactionServiceMock{}, creationService) + router.RegisterRoutes(apiV1) + + req := newMultipartRequest(t, map[string]string{ + "family_id": "1", + "created_by": "2", + }) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + assert.Contains(t, w.Body.String(), "receipt service unavailable") + }) + + t.Run("returns 504 when receipt provider times out in photo flow", func(t *testing.T) { + r := gin.New() + apiV1 := r.Group("/api/v1") + + ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) { + return "21.01.2026 " + validNumber, nil + }} + receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + return nil, context.DeadlineExceeded + }} + creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc) + router := NewTransactionsRouter(&transactionServiceMock{}, creationService) + router.RegisterRoutes(apiV1) + + req := newMultipartRequest(t, map[string]string{ + "family_id": "1", + "created_by": "2", + }) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusGatewayTimeout, w.Code) + assert.Contains(t, w.Body.String(), "receipt service timeout") + }) } func ptrInt64(v int64) *int64 { diff --git a/backend/src/api/server.go b/backend/src/api/server.go index f391793..32ae16e 100644 --- a/backend/src/api/server.go +++ b/backend/src/api/server.go @@ -129,7 +129,7 @@ func NewServer(cfg config.Config) *Server { authRouter.RegisterRouter(apiV1) // подключаем статику Vue — должно быть последним - registerStaticFiles(router) + registerStaticFiles(router, "src/api/dist") return &Server{ httpServer: &http.Server{ Addr: cfg.APIHost + ":" + cfg.APIPort, diff --git a/backend/src/api/services/receipts.go b/backend/src/api/services/receipts.go index 7128591..bf542a8 100644 --- a/backend/src/api/services/receipts.go +++ b/backend/src/api/services/receipts.go @@ -119,7 +119,7 @@ func buildReceiptTransactionDescription(receipt *domain.Receipt, explicit *strin return &value } - if name := strings.TrimSpace(receipt.NameSPD); name != "" { + if name := strings.TrimSpace(receipt.NameTO); name != "" { return &name } diff --git a/backend/src/api/static.go b/backend/src/api/static.go index b453a11..54244cd 100644 --- a/backend/src/api/static.go +++ b/backend/src/api/static.go @@ -1,26 +1,26 @@ package api import ( - "embed" - "io/fs" "net/http" + "os" "github.com/gin-gonic/gin" ) -//go:embed dist -var staticFiles embed.FS +func registerStaticFiles(router *gin.Engine, staticDir string) { + if _, err := os.Stat(staticDir); err != nil { + if os.IsNotExist(err) { + router.NoRoute(func(c *gin.Context) { + c.Status(http.StatusNotFound) + }) + return + } -func registerStaticFiles(router *gin.Engine) { - // вырезаем префикс dist/ чтобы / отдавал index.html - distFS, err := fs.Sub(staticFiles, "dist") - if err != nil { panic(err) } - fileServer := http.FileServer(http.FS(distFS)) + fileServer := http.FileServer(http.Dir(staticDir)) - // все маршруты которые не /api и не /openapi — отдаём Vue router.NoRoute(func(c *gin.Context) { fileServer.ServeHTTP(c.Writer, c.Request) }) diff --git a/backend/src/api/static_test.go b/backend/src/api/static_test.go new file mode 100644 index 0000000..c5ba62d --- /dev/null +++ b/backend/src/api/static_test.go @@ -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()) +} diff --git a/backend/src/integrations/receiptProvider/receipt_provider.go b/backend/src/integrations/receiptProvider/receipt_provider.go index 9c00be7..4f10eb2 100644 --- a/backend/src/integrations/receiptProvider/receipt_provider.go +++ b/backend/src/integrations/receiptProvider/receipt_provider.go @@ -172,7 +172,12 @@ func buildMultipartBody(date, number string) (*bytes.Buffer, string) { body := &bytes.Buffer{} writer := multipart.NewWriter(body) - _ = writer.WriteField("orig_date", date) + normalizedDate := strings.TrimSpace(date) + if isoDate, err := utils.NormalizeDateToISO(normalizedDate); err == nil { + normalizedDate = isoDate + } + + _ = writer.WriteField("orig_date", normalizedDate) _ = writer.WriteField("orig_ui", number) _ = writer.Close() diff --git a/backend/src/utils/date.go b/backend/src/utils/date.go index 660a9c9..60c7126 100644 --- a/backend/src/utils/date.go +++ b/backend/src/utils/date.go @@ -13,9 +13,9 @@ var knownDateFormats = []string{ "02.01.06", // 21.01.2026 "02-01-2006", // 21-01-2026 "02/01/2006", // 21/01/2026 - //"2006/01/02", // 2026/01/21 - //"2006-01-02", // 2026-01-21 - //"2006.01.02", // 2026.01.21 + "2006/01/02", // 2026/01/21 + "2006-01-02", // 2026-01-21 + "2006.01.02", // 2026.01.21 } func NormalizeDateToISO(input string) (string, error) { diff --git a/backend/src/utils/parser.go b/backend/src/utils/parser.go index 2705a85..6c5ff32 100644 --- a/backend/src/utils/parser.go +++ b/backend/src/utils/parser.go @@ -1,6 +1,10 @@ package utils -import "regexp" +import ( + "regexp" + "strings" + "time" +) type ReceiptMeta struct { Date string @@ -10,17 +14,16 @@ type ReceiptMeta struct { func ExtractReceiptMeta(text string) ReceiptMeta { result := ReceiptMeta{} - // --- ДАТА --- datePatterns := []string{ - `(\d{2}[./-]\d{2}[./-]\d{4})`, // 25.01.2026 - `(\d{2}[./-]\d{2}[./-]\d{2})`, // 25.01.26 - `(\d{4}[./-]\d{2}[./-]\d{2})`, // 2026-01-25 + `\b\d{2}[./:-]\d{2}[./:-]\d{4}\b`, // 25.01.2026, 25:01.2026 + `\b\d{4}[./:-]\d{2}[./:-]\d{2}\b`, // 2026-01-25 + `\b\d{2}[./-]\d{2}[./-]\d{2}\b`, // 25.01.26 } for _, pattern := range datePatterns { re := regexp.MustCompile(pattern) if match := re.FindString(text); match != "" { - result.Date = match + result.Date = normalizeOCRDate(match) break } } @@ -31,3 +34,15 @@ func ExtractReceiptMeta(text string) ReceiptMeta { return result } + +func normalizeOCRDate(value string) string { + sanitized := strings.NewReplacer(":", ".", "/", ".", "-", ".").Replace(strings.TrimSpace(value)) + + for _, layout := range knownDateFormats { + if t, err := time.Parse(layout, sanitized); err == nil { + return t.Format("02.01.2006") + } + } + + return strings.TrimSpace(value) +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0014ebc..1e2f6ff 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,23 +1,22 @@ diff --git a/frontend/src/api/transactions.ts b/frontend/src/api/transactions.ts index c9e1cbd..55771c1 100644 --- a/frontend/src/api/transactions.ts +++ b/frontend/src/api/transactions.ts @@ -41,3 +41,67 @@ export async function getTransactions(options: GetTransactionsOptions = {}): Pro const payload = await response.json() as TransactionsResponse return Array.isArray(payload.items) ? payload.items : [] } + +export interface CreateTransactionData { + family_id: number + type?: string + category?: string + amount?: number + datetime?: string + description?: string + receipt_number?: string + receipt_date?: string +} + +// TODO: Replace with the authenticated user id when frontend auth is implemented. +const TRANSACTION_CREATOR_ID = 1 + +export async function createTransaction(data: CreateTransactionData): Promise { + 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 +} + +export interface CreateTransactionPhotoData { + photo: File + family_id: number + type?: string + category?: string + description?: string +} + +export async function createTransactionFromPhoto(data: CreateTransactionPhotoData): Promise { + 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 +} diff --git a/frontend/src/components/AddTransactionScreen.vue b/frontend/src/components/AddTransactionScreen.vue new file mode 100644 index 0000000..455ecf2 --- /dev/null +++ b/frontend/src/components/AddTransactionScreen.vue @@ -0,0 +1,672 @@ +