diff --git a/API/routes/receipt_routes.py b/API/routes/receipt_routes.py index 5a90df0..7f59083 100644 --- a/API/routes/receipt_routes.py +++ b/API/routes/receipt_routes.py @@ -3,7 +3,7 @@ from services.receipt_service import IReceiptService from utils.helpers.jwt_utils import JwtUtils -router = APIRouter(prefix="/receipt", tags=["Receipt"]) +router = APIRouter(tags=["Receipt"]) def get_current_user_id(request: Request) -> int: """ diff --git a/Web/React/src/components/Receipts.tsx b/Web/React/src/components/Receipts.tsx index 9351c9a..d74df60 100644 --- a/Web/React/src/components/Receipts.tsx +++ b/Web/React/src/components/Receipts.tsx @@ -6,54 +6,159 @@ import ReceiptsUpload from './ReceiptsUpload'; import ReceiptsCamera from './ReceiptsCamera'; import { useAuth } from '../hooks/useAuth'; import groupService, { Group } from '../services/group-service'; - -export type ReceiptItem = { - id: number; - title: string; - subtitle: string; - amount: number; - dateTransaction: string; - dateAdded: string; - isGroup: boolean; - groupId?: number; - groupName?: string; - addedBy?: string; - initial?: string; -}; +import receiptService, { ReceiptAnalysisResult } from '../services/receipt-service'; +import apiClient from '../services/api-client'; +import categoryService, { Category } from '../services/category-service'; const Receipts: React.FC<{ navigate?: (to: string) => void }> = ({ navigate }) => { const { user } = useAuth(); - // Modified: default entry is the "chooseAdd" step so Receipts page shows the Add flow directly. - const [subpage, setSubpage] = useState<'menu'|'chooseAdd'|'addOptions'|'view'|'upload'|'camera'>('chooseAdd'); + const [subpage, setSubpage] = useState<'menu'|'chooseAdd'|'addOptions'|'view'|'upload'|'camera'|'review'>('chooseAdd'); - const [groups, setGroups] = useState([]); - const [loadingGroups, setLoadingGroups] = useState(false); + // AI Data + const [analysisData, setAnalysisData] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [statusMessage, setStatusMessage] = useState(""); // For showing "Saving item 1 of 5..." - // groupId when adding a group-linked receipt + // Data + const [groups, setGroups] = useState([]); + const [categories, setCategories] = useState([]); const [selectedGroup, setSelectedGroup] = useState(null); - - // refreshKey: increment to trigger ReceiptsView reload const [refreshKey, setRefreshKey] = useState(0); - // Fetch groups on mount + // Load Groups and Categories on mount useEffect(() => { if (!user) return; - const fetchGroups = async () => { - setLoadingGroups(true); + const fetchData = async () => { try { - const data = await groupService.getUserGroups(user.id); - setGroups(data); + const [groupsData, catsData] = await Promise.all([ + groupService.getUserGroups(user.id), + categoryService.getCategories() + ]); + setGroups(groupsData); + setCategories(catsData); } catch (err) { - console.error('Failed to load groups', err); - } finally { - setLoadingGroups(false); + console.error('Failed to load initial data', err); } }; - fetchGroups(); + fetchData(); }, [user]); - const triggerRefresh = () => { - setRefreshKey(prev => prev + 1); + const triggerRefresh = () => setRefreshKey(prev => prev + 1); + + // --- DELETE HANDLER --- + const handleDeleteItem = (indexToDelete: number) => { + if (!analysisData) return; + const updatedItems = analysisData.items.filter((_, idx) => idx !== indexToDelete); + + // Recalculate Total + const newTotal = updatedItems.reduce((sum, item) => sum + item.price, 0); + + setAnalysisData({ + items: updatedItems, + total: newTotal + }); + }; + + const handleFileProcess = async (file: File) => { + if (!user) { alert("Login required"); return; } + setIsProcessing(true); + setStatusMessage("Analyzing receipt..."); + try { + const result = await receiptService.processReceipt(file); + setAnalysisData(result); + setSubpage('review'); + } catch (error) { + console.error(error); + alert("AI Processing failed."); + } finally { + setIsProcessing(false); + setStatusMessage(""); + } + }; + + // --- SAVE HANDLER (Matches Mobile Logic) --- + // Replace your handleSaveConfirmed with this corrected version: + const handleSaveConfirmed = async () => { + if (!analysisData || analysisData.items.length === 0) return; + + setIsProcessing(true); + setStatusMessage("Saving items..."); + + // 1. Create a map for fast lookup + const categoryMap = new Map(); + categories.forEach(c => categoryMap.set(c.title.toLowerCase(), c)); + + let successCount = 0; + + try { + for (let i = 0; i < analysisData.items.length; i++) { + const item = analysisData.items[i]; + setStatusMessage(`Saving item ${i + 1} of ${analysisData.items.length}...`); + + // Normalize category name from AI (e.g., "Food " -> "food") + const aiCategoryName = item.category.trim(); + const aiCategoryKey = aiCategoryName.toLowerCase(); + + let targetCategory = categoryMap.get(aiCategoryKey); + + // 2. If Category doesn't exist, Create it + if (!targetCategory) { + try { + // Call API to create category + const res: any = await categoryService.createCategory(aiCategoryName); + + // CORRECTLY PARSE RESPONSE: + // The backend returns { success: true, data: { id: 123 } } + // Depending on your http-service, 'res' might be the whole object or just the data. + const newId = res?.data?.id || res?.id; + + if (newId) { + // Construct the category object manually since we have the ID now + const newCat: Category = { + id: newId, + title: aiCategoryName, + user_id: user!.id, + keywords: item.keywords || [] + }; + + targetCategory = newCat; + categoryMap.set(aiCategoryKey, newCat); // Update map for next items + setCategories(prev => [...prev, newCat]); // Update UI state + } + } catch (err) { + console.error(`Failed to create category '${aiCategoryName}'`, err); + // ONLY fallback if creation actually failed + if (categories.length > 0) targetCategory = categories[0]; + } + } + + // 3. Save Expense + if (targetCategory) { + const payload = { + title: item.name, + amount: item.price, + category_id: targetCategory.id, + group_id: selectedGroup || undefined, + description: `AI Imported. Tags: ${item.keywords?.join(', ')}` + }; + await apiClient.post('/expenses', payload); + successCount++; + } + } + + alert(`Successfully saved ${successCount} items!`); + triggerRefresh(); + setSubpage('chooseAdd'); + setAnalysisData(null); + setSelectedGroup(null); + + } catch (error: any) { + console.error("Batch save failed:", error); + alert("An error occurred while saving expenses."); + } finally { + setIsProcessing(false); + setStatusMessage(""); + } }; return ( @@ -64,110 +169,145 @@ const Receipts: React.FC<{ navigate?: (to: string) => void }> = ({ navigate }) =
History & uploads
- {/* Choose single or group */} + {/* Loading Overlay */} + {isProcessing && ( +
+
+
{statusMessage || "Processing..."}
+
+ )} + + {/* 1. CHOOSE ADD / MENU */} {subpage === 'chooseAdd' && (
-
Add receipt: choose type
- +
Add receipt: choose type
- - -
- - + +
+ + +
- -
- -
-
)} - {/* Add options (manual / upload / camera) */} + {/* 2. ADD OPTIONS */} {subpage === 'addOptions' && ( -
-
How do you want to add the receipt?
- {selectedGroup && ( -
- Adding to group: {groups.find(g => g.id === selectedGroup)?.name} -
- )} - -
- {/* Manual add removed: use Home 'Add Expense' instead */} - -
- -
Upload PDF / PNG / JPG.
+
+
How do you want to add the receipt?
+ {selectedGroup &&
Adding to group: {groups.find(g=>g.id===selectedGroup)?.name}
} + +
+
+ +
Upload PDF / PNG / JPG.
+
+
+ +
Take a photo directly.
+
+ +
+ )} -
- -
Open camera and take a photo.
-
-
+ {/* 3. UPLOAD & CAMERA */} + {subpage === 'upload' && } + {subpage === 'camera' && } -
- -
-
- )} + {/* 4. REVIEW SCREEN */} + {subpage === 'review' && analysisData && ( +
+
Review Receipt
+
+ Review items before saving. +
+ +
+ {/* Items List */} +
+ {analysisData.items.map((item, index) => ( +
+ {/* Info */} +
+
{item.name}
+
+ + {item.category} + + {item.quantity > 1 && `x${item.quantity}`} +
+
+ {/* Price */} +
+ {item.price.toFixed(2)} +
+ {/* DELETE BUTTON */} + +
+ ))} +
- {/* UPLOAD */} - {subpage === 'upload' && ( -
-
-
Add receipt — Upload
-
- -
-
- -
- { triggerRefresh(); setSubpage('chooseAdd'); }} - groupId={selectedGroup} - groups={groups} - /> -
-
- )} + {/* Total */} +
+ Total + {analysisData.total.toFixed(2)} +
+
- {/* CAMERA */} - {subpage === 'camera' && ( -
-
-
Add receipt — Camera
-
- -
-
- -
- { triggerRefresh(); setSubpage('chooseAdd'); }} - groupId={selectedGroup} - groups={groups} - /> -
+ {/* Actions */} +
+ + +
)}
); }; -export default Receipts; - +export default Receipts; \ No newline at end of file diff --git a/Web/React/src/components/ReceiptsCamera.tsx b/Web/React/src/components/ReceiptsCamera.tsx index 5e697ec..b17a845 100644 --- a/Web/React/src/components/ReceiptsCamera.tsx +++ b/Web/React/src/components/ReceiptsCamera.tsx @@ -1,121 +1,121 @@ // src/components/ReceiptsCamera.tsx -import React, { useRef, useState } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import '../App.css'; -import type { ReceiptItem } from './Receipts'; import type { Group } from '../services/group-service'; interface ReceiptsCameraProps { - onUploaded: (it: ReceiptItem) => void; + // CHANGED: Pass the captured file back to parent + onPhotoTaken: (file: File) => void; groupId?: number | null; - groups: Group[]; + groups?: Group[]; } -async function uploadMockFromBlob(blob: Blob, linkGroupId?: number | null): Promise { - await new Promise(r => setTimeout(r, 900)); - const now = new Date().toISOString().slice(0,10); - return { - id: Date.now(), - title: 'photo-' + Date.now(), - subtitle: 'Camera (mock)', - amount: 50, - dateTransaction: now, - dateAdded: now, - isGroup: !!linkGroupId, - groupId: linkGroupId || undefined, - groupName: linkGroupId ? `Group ${linkGroupId}` : undefined, - addedBy: 'You', - initial: 'Y', - }; -} - -const ReceiptsCamera: React.FC = ({ onUploaded, groupId = null, groups }) => { +const ReceiptsCamera: React.FC = ({ onPhotoTaken }) => { const videoRef = useRef(null); const canvasRef = useRef(null); const [stream, setStream] = useState(null); - const [taking, setTaking] = useState(false); - const [selectedGroup, setSelectedGroup] = useState(groupId ?? null); + const [errorMsg, setErrorMsg] = useState(null); + + // Auto-start camera on mount, cleanup on unmount + useEffect(() => { + startCamera(); + return () => { + stopCamera(); + }; + }, []); const startCamera = async () => { if (stream) return; try { - const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false }); + // Prefer back camera ('environment') + const s = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment' }, + audio: false + }); setStream(s); - if (videoRef.current) { videoRef.current.srcObject = s; await videoRef.current.play(); } + if (videoRef.current) { + videoRef.current.srcObject = s; + await videoRef.current.play(); + } } catch (err) { console.error('Camera error', err); - alert('Cannot access camera.'); + setErrorMsg('Cannot access camera. Please ensure permissions are granted.'); } }; const stopCamera = () => { - if (stream) { stream.getTracks().forEach(t => t.stop()); setStream(null); } - if (videoRef.current) { try { videoRef.current.pause(); videoRef.current.srcObject = null; } catch{} } + if (stream) { + stream.getTracks().forEach(t => t.stop()); + setStream(null); + } + if (videoRef.current) { + videoRef.current.srcObject = null; + } }; - const takePhoto = async () => { - if (!videoRef.current) return; - setTaking(true); - try { - const vw = videoRef.current.videoWidth; - const vh = videoRef.current.videoHeight; - const canvas = canvasRef.current!; - canvas.width = vw; canvas.height = vh; - const ctx = canvas.getContext('2d')!; - ctx.drawImage(videoRef.current, 0, 0, vw, vh); - canvas.toBlob(async (blob) => { - if (!blob) { alert('Capture failed'); setTaking(false); return; } - try { - const result = await uploadMockFromBlob(blob, selectedGroup); - onUploaded(result); - stopCamera(); - } catch (err) { - console.error(err); - alert('Upload failed (mock)'); - } finally { - setTaking(false); - } - }, 'image/jpeg', 0.9); - } catch (err) { - console.error(err); - setTaking(false); - } + const capturePhoto = () => { + if (!videoRef.current || !canvasRef.current) return; + + const vw = videoRef.current.videoWidth; + const vh = videoRef.current.videoHeight; + const canvas = canvasRef.current; + + canvas.width = vw; + canvas.height = vh; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.drawImage(videoRef.current, 0, 0, vw, vh); + + // Convert canvas to Blob, then to File + canvas.toBlob((blob) => { + if (!blob) { + alert('Capture failed'); + return; + } + + // Create a File object from the Blob + const file = new File([blob], `camera_capture_${Date.now()}.jpg`, { type: "image/jpeg" }); + + stopCamera(); // Stop stream + onPhotoTaken(file); // Send to parent for AI processing + }, 'image/jpeg', 0.95); }; return (
-
Use your device camera to capture the receipt.
- -
-
-
Link to group (optional):
- -
+ {errorMsg &&
{errorMsg}
} -
- - -
+
+
-
-
); }; -export default ReceiptsCamera; - +export default ReceiptsCamera; \ No newline at end of file diff --git a/Web/React/src/components/ReceiptsUpload.tsx b/Web/React/src/components/ReceiptsUpload.tsx index 845c8e2..e8cd4e9 100644 --- a/Web/React/src/components/ReceiptsUpload.tsx +++ b/Web/React/src/components/ReceiptsUpload.tsx @@ -1,51 +1,30 @@ // src/components/ReceiptsUpload.tsx import React, { useRef, useState } from 'react'; import '../App.css'; -import type { ReceiptItem } from './Receipts'; import type { Group } from '../services/group-service'; -/* - Upload page: file picker + "drop file" area + max size enforcement. - Calls onUploaded with a mock created ReceiptItem. - TODO: replace uploadMock(...) with actual API call to backend OCR endpoint. -*/ - const MAX_BYTES = 5 * 1024 * 1024; // 5 MB -const allowedTypes = ['image/png','image/jpeg','image/jpg','application/pdf']; - -async function uploadMock(file: File, linkGroupId?: number | null): Promise { - // TODO: replace with real upload + OCR API - await new Promise(r => setTimeout(r, 800)); - const now = new Date().toISOString().slice(0,10); - return { - id: Date.now(), - title: file.name.replace(/\.[^.]+$/, ''), - subtitle: 'Scanned (mock)', - amount: Math.round((Math.random()*200+1) * (Math.random()>0.7?1:-1)), - dateTransaction: now, - dateAdded: now, - isGroup: !!linkGroupId, - groupId: linkGroupId || undefined, - groupName: linkGroupId ? `Group ${linkGroupId}` : undefined, - addedBy: 'You', - initial: 'Y', - }; -} +const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'application/pdf']; interface ReceiptsUploadProps { - onUploaded: (it: ReceiptItem) => void; - groupId?: number | null; - groups: Group[]; // New Prop + // CHANGED: We now pass the raw File back, not a processed Item + onFileSelected: (file: File) => void; + // We keep these for type compatibility, though the parent now manages the group logic + groupId?: number | null; + groups?: Group[]; } -const ReceiptsUpload: React.FC = ({ onUploaded, groupId = null, groups }) => { +const ReceiptsUpload: React.FC = ({ onFileSelected }) => { const inputRef = useRef(null); - const [busy, setBusy] = useState(false); - const [selectedGroup, setSelectedGroup] = useState(groupId ?? null); + const [errorMsg, setErrorMsg] = useState(null); - const handleFiles = async (fileList: FileList | null) => { + const handleFiles = (fileList: FileList | null) => { + setErrorMsg(null); if (!fileList || fileList.length === 0) return; + const file = fileList[0]; + + // Validation if (file.size > MAX_BYTES) { alert('File too large. Max size: 5 MB.'); return; @@ -55,23 +34,20 @@ const ReceiptsUpload: React.FC = ({ onUploaded, groupId = n return; } - setBusy(true); - try { - const result = await uploadMock(file, selectedGroup); - onUploaded(result); - } catch (err) { - console.error(err); - alert('Upload failed (mock).'); - } finally { - setBusy(false); - } + // Pass file to parent to trigger AI analysis + onFileSelected(file); }; const onDrop = (e: React.DragEvent) => { - e.preventDefault(); e.stopPropagation(); + e.preventDefault(); + e.stopPropagation(); handleFiles(e.dataTransfer.files); }; - const onDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }; + + const onDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; return (
@@ -81,47 +57,38 @@ const ReceiptsUpload: React.FC = ({ onUploaded, groupId = n onDrop={onDrop} onDragOver={onDragOver} style={{ - border: '2px dashed rgba(0,0,0,0.06)', - padding: 18, + border: '2px dashed #ccc', + padding: 30, borderRadius: 10, display: 'flex', + flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', - background: 'linear-gradient(180deg, rgba(255,255,255,0.01), rgba(255,255,255,0.005))' + background: '#f9f9f9', + minHeight: 150 }} onClick={() => inputRef.current?.click()} > -
-
Drop file here
-
or click to open file dialog
-
+
Drop file here
+
or click to open file dialog
- handleFiles(e.target.files)} /> + handleFiles(e.target.files)} + /> -
-
-
Link to group (optional):
- -
- -
- -
+
+
); }; -export default ReceiptsUpload; - +export default ReceiptsUpload; \ No newline at end of file diff --git a/Web/React/src/services/receipt-service.ts b/Web/React/src/services/receipt-service.ts new file mode 100644 index 0000000..4286b50 --- /dev/null +++ b/Web/React/src/services/receipt-service.ts @@ -0,0 +1,32 @@ +// src/services/receipt-service.ts +import apiClient from './api-client'; // Import your configured client + +export interface ReceiptItem { + name: string; + quantity: number; + price: number; + category: string; + keywords: string[]; +} + +export interface ReceiptAnalysisResult { + items: ReceiptItem[]; + total: number; +} + +const processReceipt = async (file: File): Promise => { + const formData = new FormData(); + formData.append('image', file); + + const response = await apiClient.post('/receipt/process-receipt', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return response.data; +}; + +export default { + processReceipt +}; \ No newline at end of file