diff --git a/backend/controllers/receiptController.js b/backend/controllers/receiptController.js index e20be11..42c63ae 100644 --- a/backend/controllers/receiptController.js +++ b/backend/controllers/receiptController.js @@ -75,19 +75,6 @@ const uploadReceipt = async (req, res) => { const savedReceipt = await newReceipt.save(); - // Automatically create a corresponding expense transaction - if (savedReceipt) { - const newTransaction = new IncomeExpense({ - user: req.user.id, - name: savedReceipt.extractedData.merchant, - category: savedReceipt.extractedData.category, - cost: savedReceipt.extractedData.amount, - addedOn: savedReceipt.extractedData.date, - isIncome: false, - }); - await newTransaction.save(); - } - res.status(201).json(savedReceipt); } catch (error) { console.error("Error with Gemini API:", error); @@ -103,6 +90,65 @@ const uploadReceipt = async (req, res) => { } }; +// @desc Save transaction after user confirmation and edits +// @route POST /api/receipts/save-transaction +// @access Private +const saveTransactionFromReceipt = async (req, res) => { + try { + const { receiptId, transactionData } = req.body; + + // Validate required fields + if (!receiptId || !transactionData) { + return res.status(400).json({ message: 'Receipt ID and transaction data are required' }); + } + + // Verify the receipt belongs to the user + const receipt = await Receipt.findOne({ _id: receiptId, user: req.user.id }); + if (!receipt) { + return res.status(404).json({ message: 'Receipt not found' }); + } + + // Validate and parse the date + const transactionDate = new Date(transactionData.addedOn); + if (isNaN(transactionDate.getTime())) { + return res.status(400).json({ message: 'Invalid date format provided' }); + } + + // Create the transaction with user-confirmed data + const newTransaction = new IncomeExpense({ + user: req.user.id, + name: transactionData.name, + category: transactionData.category, + cost: transactionData.cost, + addedOn: transactionDate, + isIncome: transactionData.isIncome || false, + }); + + const savedTransaction = await newTransaction.save(); + + // Update the receipt with the final confirmed data + receipt.extractedData = { + merchant: transactionData.name, + amount: transactionData.cost, + category: transactionData.category, + date: transactionDate, + isIncome: transactionData.isIncome || false, + }; + await receipt.save(); + + res.status(201).json({ + message: 'Transaction saved successfully', + transaction: savedTransaction, + receipt: receipt + }); + + } catch (error) { + console.error('Error saving transaction:', error); + res.status(500).json({ message: 'Failed to save transaction', error: error.message }); + } +}; + module.exports = { uploadReceipt, + saveTransactionFromReceipt, }; diff --git a/backend/routes/receiptRoutes.js b/backend/routes/receiptRoutes.js index 8e192d3..c9849b3 100644 --- a/backend/routes/receiptRoutes.js +++ b/backend/routes/receiptRoutes.js @@ -1,9 +1,10 @@ const express = require('express'); const router = express.Router(); -const { uploadReceipt } = require('../controllers/receiptController'); +const { uploadReceipt, saveTransactionFromReceipt } = require('../controllers/receiptController'); const { protect } = require('../middleware/authMiddleware'); const upload = require('../middleware/uploadMiddleware'); router.post('/upload', protect, upload, uploadReceipt); +router.post('/save-transaction', protect, saveTransactionFromReceipt); module.exports = router; \ No newline at end of file diff --git a/frontend/src/pages/ReceiptsPage.jsx b/frontend/src/pages/ReceiptsPage.jsx index ef72ccb..7082bf9 100644 --- a/frontend/src/pages/ReceiptsPage.jsx +++ b/frontend/src/pages/ReceiptsPage.jsx @@ -1,148 +1,291 @@ import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import api from "../api/axios"; +import TransactionModal from "../components/TransactionModal"; import { toast, Bounce } from "react-toastify"; + const ReceiptsPage = () => { - const [isMobile, setIsMobile] = useState(window.innerWidth <= 767); - const [file, setFile] = useState(null); - const [uploading, setUploading] = useState(false); - const [receiptResult, setReceiptResult] = useState(null); - const [error, setError] = useState(""); - const navigate = useNavigate(); - - useEffect(() => { - const handleResize = () => { - return setIsMobile(window.innerWidth <= 767); - }; - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); - - const handleFileChange = (e) => { - setFile(e.target.files[0]); - setReceiptResult(null); - setError(""); - }; - - const handleSubmit = async (e) => { - e.preventDefault(); - if (!file) { - setError("Please select a file to upload."); - return; - } - - const formData = new FormData(); - formData.append("receipt", file); - - try { - setUploading(true); - setError(""); - const response = await api.post("/receipts/upload", formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - setReceiptResult(response.data); - - toast.success("Receipt processed successfully and transaction created!", { - position: "top-right", - autoClose: 3000, - hideProgressBar: false, - closeOnClick: false, - pauseOnHover: true, - draggable: true, - progress: undefined, - style: { - fontSize: "18px", // increases text size - padding: "16px 24px", // increases toast size - minWidth: "500px", // optional: increase width - }, - theme: "light", - transition: Bounce, - }); - } catch (err) { - setError("Upload failed. Please try again."); - console.error(err); - } finally { - setUploading(false); - } - }; - - return ( - <> -

- Upload Receipt -

-
-
-
- - - - {error &&

{error}

} -
-
- -
-

- Last Upload Result -

- {receiptResult ? ( -
-

- Merchant:{" "} - {receiptResult.extractedData.merchant} -

-

- Amount:{" "} - {receiptResult.extractedData.amount.toFixed(2)} -

-

- Category:{" "} - {receiptResult.extractedData.category} -

-

- Date:{" "} - {new Date( - receiptResult.extractedData.date - ).toLocaleDateString()} -

- Uploaded Receipt -
- ) : ( -

- Upload a receipt to see the extracted data here. -

- )} -
- {isMobile && ( - - )} -
- - ); + const [isMobile, setIsMobile] = useState(window.innerWidth <= 767); + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [receiptResult, setReceiptResult] = useState(null); + const [error, setError] = useState(""); + const navigate = useNavigate(); + + const [openEditReceiptResult, setOpenEditReceiptResult] = useState(false); + const [categories, setCategories] = useState([]); + const [isSaving, setIsSaving] = useState(false); + + // Fetch categories when component mounts + useEffect(() => { + const fetchCategories = async () => { + try { + const response = await api.get("/transactions/categories"); + setCategories(response.data); + } catch (error) { + console.error("Failed to fetch categories:", error); + } + }; + + fetchCategories(); + }, []); + + // Handle mobile responsive resize + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth <= 767); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + const handleFileChange = (e) => { + setFile(e.target.files[0]); + setReceiptResult(null); + setError(""); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!file) { + setError("Please select a file to upload."); + return; + } + + const formData = new FormData(); + formData.append("receipt", file); + + try { + setUploading(true); + setError(""); + const response = await api.post("/receipts/upload", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + setReceiptResult(response.data); + + // Open the modal to allow user to edit the extracted data + setOpenEditReceiptResult(true); + } catch (err) { + setError("Upload failed. Please try again."); + console.error(err); + } finally { + setUploading(false); + } + }; + + const handleEditReceiptSubmit = (formData) => { + // Update the receiptResult with the edited data + const updatedReceiptResult = { + ...receiptResult, + extractedData: { + merchant: formData.name, + amount: parseFloat(formData.cost) || 0, + category: formData.category, + date: formData.addedOn, + isIncome: formData.isIncome, + }, + }; + + setReceiptResult(updatedReceiptResult); + setOpenEditReceiptResult(false); + }; + + // Handle final save to database (second verification step) + const handleFinalSave = async () => { + try { + setIsSaving(true); + + const transactionData = { + name: receiptResult.extractedData.merchant, + category: receiptResult.extractedData.category, + cost: receiptResult.extractedData.amount, + addedOn: receiptResult.extractedData.date, + isIncome: receiptResult.extractedData.isIncome || false, + }; + + await api.post("/receipts/save-transaction", { + receiptId: receiptResult._id, + transactionData: transactionData, + }); + + toast.success("Transaction saved successfully!", { + position: "top-right", + autoClose: 3000, + hideProgressBar: false, + closeOnClick: false, + pauseOnHover: true, + draggable: true, + progress: undefined, + style: { + fontSize: "18px", + padding: "16px 24px", + minWidth: "500px", + }, + theme: "light", + transition: Bounce, + }); + + setTimeout(() => navigate("/dashboard"), 1000); + } catch (err) { + setError("Failed to save transaction. Please try again."); + console.error(err); + } finally { + setIsSaving(false); + } + }; + + // Handle edit button in result div + const handleEditResult = () => { + setOpenEditReceiptResult(true); + }; + + const handleNewCategory = (newCategory, isIncome) => { + setCategories((prev) => + [...prev, { name: newCategory, isIncome }].sort((a, b) => + a.name.localeCompare(b.name) + ) + ); + }; + + return ( + <> +

+ Upload Receipt +

+
+
+
+ + + + {error && ( +

{error}

+ )} +
+
+ +
+

+ Last Upload Result +

+ {receiptResult ? ( +
+
+

+ Merchant:{" "} + {receiptResult.extractedData.merchant} +

+

+ Amount:{" "} + {( + parseFloat( + receiptResult.extractedData.amount + ) || 0 + ).toFixed(2)} +

+

+ Category:{" "} + {receiptResult.extractedData.category} +

+

+ Date:{" "} + {new Date( + receiptResult.extractedData.date + ).toLocaleDateString()} +

+ {receiptResult.extractedData.isIncome && ( +

+ Income: Yes +

+ )} +
+ +
+ + +
+ + Uploaded Receipt +
+ ) : ( +

+ Upload a receipt to see the extracted data here. +

+ )} +
+
+ + {isMobile && ( + + )} + + {/* Transaction Modal for editing receipt data */} + {openEditReceiptResult && receiptResult && ( + { + setOpenEditReceiptResult(false); + }} + onSubmit={handleEditReceiptSubmit} + transaction={{ + name: receiptResult.extractedData.merchant || "", + category: receiptResult.extractedData.category || "", + cost: receiptResult.extractedData.amount || 0, + addedOn: + receiptResult.extractedData.date || + new Date().toISOString().split("T")[0], + isIncome: receiptResult.extractedData.isIncome || false, + }} + expenseCategories={categories + .filter((cat) => !cat.isIncome) + .map((cat) => cat.name || cat)} + incomeCategories={categories + .filter((cat) => cat.isIncome) + .map((cat) => cat.name || cat)} + onNewCategory={handleNewCategory} + /> + )} + + ); }; export default ReceiptsPage;