Initial Version
This commit is contained in:
parent
d6ce2cbcec
commit
00f18baa2d
92
QUICKSTART.md
Normal file
92
QUICKSTART.md
Normal file
@ -0,0 +1,92 @@
|
||||
# 🚀 Quick Start Guide
|
||||
|
||||
Get your Grocery Tracker up and running in minutes!
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.8+** (for backend)
|
||||
- **Node.js 16+** (for frontend)
|
||||
|
||||
## Option 1: Install Node.js
|
||||
|
||||
Choose one of these methods:
|
||||
|
||||
### **Method A: Homebrew (Recommended)**
|
||||
```bash
|
||||
brew install node
|
||||
```
|
||||
|
||||
### **Method B: Official Installer**
|
||||
1. Visit [nodejs.org](https://nodejs.org/)
|
||||
2. Download LTS version for macOS
|
||||
3. Run the installer
|
||||
|
||||
### **Method C: Node Version Manager**
|
||||
```bash
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||
source ~/.zshrc
|
||||
nvm install --lts
|
||||
```
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### **1. Start the Backend** 🐍
|
||||
```bash
|
||||
cd backend
|
||||
python3 run_dev.py
|
||||
```
|
||||
|
||||
This will:
|
||||
- Create a virtual environment
|
||||
- Install Python dependencies
|
||||
- Create SQLite database
|
||||
- Start FastAPI server at http://localhost:8000
|
||||
|
||||
### **2. Start the Frontend** ⚛️
|
||||
Open a **new terminal** and run:
|
||||
```bash
|
||||
cd frontend
|
||||
./setup.sh # or: bash setup.sh
|
||||
npm start
|
||||
```
|
||||
|
||||
This will:
|
||||
- Install React dependencies
|
||||
- Start development server at http://localhost:3000
|
||||
|
||||
## 🎉 You're Ready!
|
||||
|
||||
- **Frontend**: http://localhost:3000
|
||||
- **Backend API**: http://localhost:8000
|
||||
- **API Docs**: http://localhost:8000/docs
|
||||
|
||||
## First Steps
|
||||
|
||||
1. **Add a Shop**: Go to "Shops" and add your first grocery store
|
||||
2. **Add Groceries**: Go to "Groceries" and add some items
|
||||
3. **Record a Purchase**: Use "Add Purchase" to record your shopping
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend Issues
|
||||
- **Dependencies fail**: Make sure Python 3.8+ is installed
|
||||
- **Database errors**: The app uses SQLite by default (no PostgreSQL needed)
|
||||
|
||||
### Frontend Issues
|
||||
- **Node not found**: Install Node.js using one of the methods above
|
||||
- **npm install fails**: Try deleting `node_modules` and running `npm install` again
|
||||
|
||||
### Connection Issues
|
||||
- Make sure both servers are running
|
||||
- Backend should be on port 8000, frontend on port 3000
|
||||
- Check firewall settings if needed
|
||||
|
||||
## What's Included
|
||||
|
||||
✅ **Complete Backend**: FastAPI with SQLAlchemy and SQLite
|
||||
✅ **Modern Frontend**: React with TypeScript and Tailwind CSS
|
||||
✅ **Database Models**: Groceries, Shops, Shopping Events
|
||||
✅ **API Documentation**: Automatic Swagger docs
|
||||
✅ **Beautiful UI**: Responsive design with modern components
|
||||
|
||||
Happy grocery tracking! 🛒
|
||||
239
README.md
239
README.md
@ -0,0 +1,239 @@
|
||||
# Grocery Tracker
|
||||
|
||||
A web application for tracking grocery prices and shopping events. Built with FastAPI (Python) backend and React (TypeScript) frontend.
|
||||
|
||||
## Features
|
||||
|
||||
- **Grocery Management**: Add, edit, and track grocery items with prices, categories, and organic status
|
||||
- **Shop Management**: Manage different shops with locations
|
||||
- **Shopping Events**: Record purchases with multiple groceries and amounts
|
||||
- **Price Tracking**: Monitor price changes over time
|
||||
- **Modern UI**: Clean, responsive interface built with React and Tailwind CSS
|
||||
|
||||
## Architecture
|
||||
|
||||
### Technology Stack
|
||||
|
||||
**Backend (Python):**
|
||||
- FastAPI - Modern, fast web framework
|
||||
- SQLAlchemy - SQL toolkit and ORM
|
||||
- PostgreSQL - Relational database
|
||||
- Pydantic - Data validation and settings management
|
||||
- Alembic - Database migrations
|
||||
|
||||
**Frontend (React):**
|
||||
- React 18 with TypeScript
|
||||
- React Router - Client-side routing
|
||||
- Tailwind CSS - Utility-first CSS framework
|
||||
- Axios - HTTP client for API calls
|
||||
- React Hook Form - Form handling
|
||||
|
||||
### Component Communication
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTP/REST API ┌─────────────────┐ SQL Queries ┌─────────────────┐
|
||||
│ React │ ←─────────────────→ │ FastAPI │ ←───────────────→ │ PostgreSQL │
|
||||
│ Frontend │ JSON requests │ Backend │ SQLAlchemy ORM │ Database │
|
||||
│ (Port 3000) │ JSON responses │ (Port 8000) │ │ (Port 5432) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
### Groceries
|
||||
- `id`: Primary key
|
||||
- `name`: Grocery name
|
||||
- `price`: Current price
|
||||
- `category`: Food category
|
||||
- `organic`: Boolean flag
|
||||
- `weight`: Weight/volume
|
||||
- `weight_unit`: Unit (g, kg, ml, l, piece)
|
||||
|
||||
### Shops
|
||||
- `id`: Primary key
|
||||
- `name`: Shop name
|
||||
- `city`: Location city
|
||||
- `address`: Optional full address
|
||||
|
||||
### Shopping Events
|
||||
- `id`: Primary key
|
||||
- `shop_id`: Foreign key to shops
|
||||
- `date`: Purchase date
|
||||
- `total_amount`: Optional total cost
|
||||
- `notes`: Optional notes
|
||||
- `groceries`: Many-to-many relationship with amounts
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Python 3.8+** - For the backend
|
||||
2. **Node.js 16+** - For the frontend
|
||||
3. **PostgreSQL** - Database
|
||||
|
||||
### Backend Setup
|
||||
|
||||
1. **Navigate to backend directory:**
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. **Create virtual environment:**
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **Setup database:**
|
||||
```bash
|
||||
# Create PostgreSQL database
|
||||
createdb grocery_tracker
|
||||
|
||||
# Copy environment variables
|
||||
cp env.example .env
|
||||
|
||||
# Edit .env with your database credentials
|
||||
nano .env
|
||||
```
|
||||
|
||||
5. **Run database migrations:**
|
||||
```bash
|
||||
alembic upgrade head # After setting up alembic
|
||||
```
|
||||
|
||||
6. **Start the backend server:**
|
||||
```bash
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8000`
|
||||
API docs at `http://localhost:8000/docs`
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
1. **Navigate to frontend directory:**
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Start the development server:**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:3000`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Groceries
|
||||
- `GET /groceries/` - List all groceries
|
||||
- `POST /groceries/` - Create new grocery
|
||||
- `GET /groceries/{id}` - Get specific grocery
|
||||
- `PUT /groceries/{id}` - Update grocery
|
||||
- `DELETE /groceries/{id}` - Delete grocery
|
||||
|
||||
### Shops
|
||||
- `GET /shops/` - List all shops
|
||||
- `POST /shops/` - Create new shop
|
||||
- `GET /shops/{id}` - Get specific shop
|
||||
|
||||
### Shopping Events
|
||||
- `GET /shopping-events/` - List all shopping events
|
||||
- `POST /shopping-events/` - Create new shopping event
|
||||
- `GET /shopping-events/{id}` - Get specific shopping event
|
||||
|
||||
### Statistics
|
||||
- `GET /stats/categories` - Category spending statistics
|
||||
- `GET /stats/shops` - Shop visit statistics
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Add Shops**: Start by adding shops where you buy groceries
|
||||
2. **Add Groceries**: Create grocery items with prices and categories
|
||||
3. **Record Purchases**: Use the "Add Purchase" form to record shopping events
|
||||
4. **Track Prices**: Monitor how prices change over time
|
||||
5. **View Statistics**: Analyze spending patterns by category and shop
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
**Backend:**
|
||||
```bash
|
||||
cd backend
|
||||
pytest
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm test
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
alembic revision --autogenerate -m "Description"
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
Create `docker-compose.yml` for easy deployment:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: grocery_tracker
|
||||
POSTGRES_USER: grocery_user
|
||||
POSTGRES_PASSWORD: your_password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
DATABASE_URL: postgresql://grocery_user:your_password@db:5432/grocery_tracker
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make changes
|
||||
4. Add tests
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
38
backend/database.py
Normal file
38
backend/database.py
Normal file
@ -0,0 +1,38 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Get database URL from environment with SQLite fallback for development
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
if not DATABASE_URL:
|
||||
# Default to SQLite for development if no PostgreSQL URL is provided
|
||||
DATABASE_URL = "sqlite:///./grocery_tracker.db"
|
||||
print("🔄 Using SQLite database for development")
|
||||
else:
|
||||
print(f"🐘 Using PostgreSQL database")
|
||||
|
||||
# Configure engine based on database type
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} # Needed for SQLite
|
||||
)
|
||||
else:
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# Dependency to get database session
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
14
backend/env.example
Normal file
14
backend/env.example
Normal file
@ -0,0 +1,14 @@
|
||||
# Database Configuration
|
||||
# Option 1: PostgreSQL (for production)
|
||||
# DATABASE_URL=postgresql://username:password@localhost:5432/grocery_tracker
|
||||
|
||||
# Option 2: SQLite (for development - default if DATABASE_URL is not set)
|
||||
# DATABASE_URL=sqlite:///./grocery_tracker.db
|
||||
|
||||
# Authentication (optional for basic setup)
|
||||
SECRET_KEY=your-secret-key-here
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# Development settings
|
||||
DEBUG=True
|
||||
160
backend/main.py
Normal file
160
backend/main.py
Normal file
@ -0,0 +1,160 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
import models, schemas
|
||||
from database import engine, get_db
|
||||
|
||||
# Create database tables
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="Grocery Tracker API",
|
||||
description="API for tracking grocery prices and shopping events",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS middleware for React frontend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:5173"], # React dev servers
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Grocery Tracker API", "version": "1.0.0"}
|
||||
|
||||
# Grocery endpoints
|
||||
@app.post("/groceries/", response_model=schemas.Grocery)
|
||||
def create_grocery(grocery: schemas.GroceryCreate, db: Session = Depends(get_db)):
|
||||
db_grocery = models.Grocery(**grocery.dict())
|
||||
db.add(db_grocery)
|
||||
db.commit()
|
||||
db.refresh(db_grocery)
|
||||
return db_grocery
|
||||
|
||||
@app.get("/groceries/", response_model=List[schemas.Grocery])
|
||||
def read_groceries(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
groceries = db.query(models.Grocery).offset(skip).limit(limit).all()
|
||||
return groceries
|
||||
|
||||
@app.get("/groceries/{grocery_id}", response_model=schemas.Grocery)
|
||||
def read_grocery(grocery_id: int, db: Session = Depends(get_db)):
|
||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
||||
if grocery is None:
|
||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||
return grocery
|
||||
|
||||
@app.put("/groceries/{grocery_id}", response_model=schemas.Grocery)
|
||||
def update_grocery(grocery_id: int, grocery_update: schemas.GroceryUpdate, db: Session = Depends(get_db)):
|
||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
||||
if grocery is None:
|
||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||
|
||||
update_data = grocery_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(grocery, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(grocery)
|
||||
return grocery
|
||||
|
||||
@app.delete("/groceries/{grocery_id}")
|
||||
def delete_grocery(grocery_id: int, db: Session = Depends(get_db)):
|
||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
||||
if grocery is None:
|
||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||
|
||||
db.delete(grocery)
|
||||
db.commit()
|
||||
return {"message": "Grocery deleted successfully"}
|
||||
|
||||
# Shop endpoints
|
||||
@app.post("/shops/", response_model=schemas.Shop)
|
||||
def create_shop(shop: schemas.ShopCreate, db: Session = Depends(get_db)):
|
||||
db_shop = models.Shop(**shop.dict())
|
||||
db.add(db_shop)
|
||||
db.commit()
|
||||
db.refresh(db_shop)
|
||||
return db_shop
|
||||
|
||||
@app.get("/shops/", response_model=List[schemas.Shop])
|
||||
def read_shops(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
shops = db.query(models.Shop).offset(skip).limit(limit).all()
|
||||
return shops
|
||||
|
||||
@app.get("/shops/{shop_id}", response_model=schemas.Shop)
|
||||
def read_shop(shop_id: int, db: Session = Depends(get_db)):
|
||||
shop = db.query(models.Shop).filter(models.Shop.id == shop_id).first()
|
||||
if shop is None:
|
||||
raise HTTPException(status_code=404, detail="Shop not found")
|
||||
return shop
|
||||
|
||||
# Shopping Event endpoints
|
||||
@app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse)
|
||||
def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
|
||||
# Verify shop exists
|
||||
shop = db.query(models.Shop).filter(models.Shop.id == event.shop_id).first()
|
||||
if shop is None:
|
||||
raise HTTPException(status_code=404, detail="Shop not found")
|
||||
|
||||
# Create shopping event
|
||||
db_event = models.ShoppingEvent(
|
||||
shop_id=event.shop_id,
|
||||
date=event.date,
|
||||
total_amount=event.total_amount,
|
||||
notes=event.notes
|
||||
)
|
||||
db.add(db_event)
|
||||
db.commit()
|
||||
db.refresh(db_event)
|
||||
|
||||
# Add groceries to the event
|
||||
for grocery_item in event.groceries:
|
||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_item.grocery_id).first()
|
||||
if grocery is None:
|
||||
raise HTTPException(status_code=404, detail=f"Grocery with id {grocery_item.grocery_id} not found")
|
||||
|
||||
# Insert into association table
|
||||
db.execute(
|
||||
models.shopping_event_groceries.insert().values(
|
||||
shopping_event_id=db_event.id,
|
||||
grocery_id=grocery_item.grocery_id,
|
||||
amount=grocery_item.amount
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_event)
|
||||
return db_event
|
||||
|
||||
@app.get("/shopping-events/", response_model=List[schemas.ShoppingEventResponse])
|
||||
def read_shopping_events(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
events = db.query(models.ShoppingEvent).offset(skip).limit(limit).all()
|
||||
return events
|
||||
|
||||
@app.get("/shopping-events/{event_id}", response_model=schemas.ShoppingEventResponse)
|
||||
def read_shopping_event(event_id: int, db: Session = Depends(get_db)):
|
||||
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Shopping event not found")
|
||||
return event
|
||||
|
||||
# Statistics endpoints
|
||||
@app.get("/stats/categories", response_model=List[schemas.CategoryStats])
|
||||
def get_category_stats(db: Session = Depends(get_db)):
|
||||
# This would need more complex SQL query - placeholder for now
|
||||
return []
|
||||
|
||||
@app.get("/stats/shops", response_model=List[schemas.ShopStats])
|
||||
def get_shop_stats(db: Session = Depends(get_db)):
|
||||
# This would need more complex SQL query - placeholder for now
|
||||
return []
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
58
backend/models.py
Normal file
58
backend/models.py
Normal file
@ -0,0 +1,58 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Table
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from datetime import datetime
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# Association table for many-to-many relationship between shopping events and groceries
|
||||
shopping_event_groceries = Table(
|
||||
'shopping_event_groceries',
|
||||
Base.metadata,
|
||||
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), primary_key=True),
|
||||
Column('grocery_id', Integer, ForeignKey('groceries.id'), primary_key=True),
|
||||
Column('amount', Float, nullable=False) # Amount of this grocery bought in this event
|
||||
)
|
||||
|
||||
class Grocery(Base):
|
||||
__tablename__ = "groceries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
price = Column(Float, nullable=False)
|
||||
category = Column(String, nullable=False)
|
||||
organic = Column(Boolean, default=False)
|
||||
weight = Column(Float, nullable=True) # in grams or kg
|
||||
weight_unit = Column(String, default="piece") # "g", "kg", "ml", "l", "piece"
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_groceries, back_populates="groceries")
|
||||
|
||||
class Shop(Base):
|
||||
__tablename__ = "shops"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
city = Column(String, nullable=False)
|
||||
address = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
shopping_events = relationship("ShoppingEvent", back_populates="shop")
|
||||
|
||||
class ShoppingEvent(Base):
|
||||
__tablename__ = "shopping_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
shop_id = Column(Integer, ForeignKey("shops.id"), nullable=False)
|
||||
date = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
||||
total_amount = Column(Float, nullable=True) # Total cost of the shopping event
|
||||
notes = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
shop = relationship("Shop", back_populates="shopping_events")
|
||||
groceries = relationship("Grocery", secondary=shopping_event_groceries, back_populates="shopping_events")
|
||||
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal file
@ -0,0 +1,13 @@
|
||||
fastapi>=0.104.1
|
||||
uvicorn[standard]>=0.24.0
|
||||
sqlalchemy>=2.0.23
|
||||
psycopg[binary]>=3.2.2
|
||||
alembic>=1.12.1
|
||||
pydantic>=2.5.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-multipart>=0.0.6
|
||||
python-dotenv>=1.0.0
|
||||
pytest>=7.4.3
|
||||
pytest-asyncio>=0.21.1
|
||||
httpx>=0.25.2
|
||||
73
backend/run_dev.py
Normal file
73
backend/run_dev.py
Normal file
@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Development script to run the FastAPI server with database setup
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
def run_command(command, cwd=None):
|
||||
"""Run a command and return True if successful"""
|
||||
try:
|
||||
result = subprocess.run(command, shell=True, cwd=cwd, check=True)
|
||||
return result.returncode == 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error running command: {command}")
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
# Ensure we're in the backend directory
|
||||
backend_dir = Path(__file__).parent
|
||||
os.chdir(backend_dir)
|
||||
|
||||
print("🍃 Starting Grocery Tracker Backend Development Server")
|
||||
print("=" * 50)
|
||||
|
||||
# Check if virtual environment exists
|
||||
venv_path = backend_dir / "venv"
|
||||
if not venv_path.exists():
|
||||
print("📦 Creating virtual environment...")
|
||||
if not run_command("python3 -m venv venv"):
|
||||
print("❌ Failed to create virtual environment")
|
||||
sys.exit(1)
|
||||
|
||||
# Install dependencies
|
||||
print("📦 Installing dependencies...")
|
||||
pip_cmd = "venv/bin/pip" if os.name != 'nt' else "venv\\Scripts\\pip"
|
||||
if not run_command(f"{pip_cmd} install -r requirements.txt"):
|
||||
print("❌ Failed to install dependencies")
|
||||
sys.exit(1)
|
||||
|
||||
# Create database tables
|
||||
print("🗄️ Creating database tables...")
|
||||
python_cmd = "venv/bin/python" if os.name != 'nt' else "venv\\Scripts\\python"
|
||||
create_tables_script = """
|
||||
from models import Base
|
||||
from database import engine
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("Database tables created successfully!")
|
||||
"""
|
||||
|
||||
with open("create_tables.py", "w") as f:
|
||||
f.write(create_tables_script)
|
||||
|
||||
if not run_command(f"{python_cmd} create_tables.py"):
|
||||
print("⚠️ Database tables creation failed (this is normal if database doesn't exist yet)")
|
||||
|
||||
# Clean up
|
||||
os.remove("create_tables.py")
|
||||
|
||||
# Start the server
|
||||
print("🚀 Starting FastAPI server...")
|
||||
print("📍 API will be available at: http://localhost:8000")
|
||||
print("📚 API docs will be available at: http://localhost:8000/docs")
|
||||
print("🛑 Press Ctrl+C to stop the server")
|
||||
print("=" * 50)
|
||||
|
||||
uvicorn_cmd = "venv/bin/uvicorn" if os.name != 'nt' else "venv\\Scripts\\uvicorn"
|
||||
run_command(f"{uvicorn_cmd} main:app --reload --host 0.0.0.0 --port 8000")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
95
backend/schemas.py
Normal file
95
backend/schemas.py
Normal file
@ -0,0 +1,95 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
# Base schemas
|
||||
class GroceryBase(BaseModel):
|
||||
name: str
|
||||
price: float = Field(..., gt=0)
|
||||
category: str
|
||||
organic: bool = False
|
||||
weight: Optional[float] = None
|
||||
weight_unit: str = "g"
|
||||
|
||||
class GroceryCreate(GroceryBase):
|
||||
pass
|
||||
|
||||
class GroceryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
price: Optional[float] = Field(None, gt=0)
|
||||
category: Optional[str] = None
|
||||
organic: Optional[bool] = None
|
||||
weight: Optional[float] = None
|
||||
weight_unit: Optional[str] = None
|
||||
|
||||
class Grocery(GroceryBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Shop schemas
|
||||
class ShopBase(BaseModel):
|
||||
name: str
|
||||
city: str
|
||||
address: Optional[str] = None
|
||||
|
||||
class ShopCreate(ShopBase):
|
||||
pass
|
||||
|
||||
class ShopUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
|
||||
class Shop(ShopBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Shopping Event schemas
|
||||
class GroceryInEvent(BaseModel):
|
||||
grocery_id: int
|
||||
amount: float = Field(..., gt=0)
|
||||
|
||||
class ShoppingEventBase(BaseModel):
|
||||
shop_id: int
|
||||
date: Optional[datetime] = None
|
||||
total_amount: Optional[float] = Field(None, ge=0)
|
||||
notes: Optional[str] = None
|
||||
|
||||
class ShoppingEventCreate(ShoppingEventBase):
|
||||
groceries: List[GroceryInEvent] = []
|
||||
|
||||
class ShoppingEventUpdate(BaseModel):
|
||||
shop_id: Optional[int] = None
|
||||
date: Optional[datetime] = None
|
||||
total_amount: Optional[float] = Field(None, ge=0)
|
||||
notes: Optional[str] = None
|
||||
groceries: Optional[List[GroceryInEvent]] = None
|
||||
|
||||
class ShoppingEventResponse(ShoppingEventBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
shop: Shop
|
||||
groceries: List[Grocery] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Statistics schemas
|
||||
class CategoryStats(BaseModel):
|
||||
category: str
|
||||
total_spent: float
|
||||
item_count: int
|
||||
avg_price: float
|
||||
|
||||
class ShopStats(BaseModel):
|
||||
shop_name: str
|
||||
total_spent: float
|
||||
visit_count: int
|
||||
avg_per_visit: float
|
||||
19329
frontend/package-lock.json
generated
Normal file
19329
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
frontend/package.json
Normal file
47
frontend/package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "grocery-tracker-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^3.5.0",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.8",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
18
frontend/public/index.html
Normal file
18
frontend/public/index.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Track grocery prices and shopping events"
|
||||
/>
|
||||
<title>Grocery Tracker</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
44
frontend/setup.sh
Executable file
44
frontend/setup.sh
Executable file
@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🎯 Setting up Grocery Tracker Frontend"
|
||||
echo "======================================"
|
||||
|
||||
# Check if Node.js is installed
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "❌ Node.js is not installed!"
|
||||
echo ""
|
||||
echo "Please install Node.js first:"
|
||||
echo " • Option 1: brew install node"
|
||||
echo " • Option 2: Download from https://nodejs.org/"
|
||||
echo " • Option 3: Use nvm (Node Version Manager)"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Node.js found: $(node --version)"
|
||||
echo "✅ npm found: $(npm --version)"
|
||||
|
||||
# Install dependencies
|
||||
echo ""
|
||||
echo "📦 Installing dependencies..."
|
||||
npm install
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo ""
|
||||
echo "🎉 Frontend setup complete!"
|
||||
echo ""
|
||||
echo "🚀 To start the development server:"
|
||||
echo " npm start"
|
||||
echo ""
|
||||
echo "📍 The app will be available at: http://localhost:3000"
|
||||
echo "🔗 Make sure the backend is running at: http://localhost:8000"
|
||||
echo ""
|
||||
echo "🛠️ Other useful commands:"
|
||||
echo " npm run build - Build for production"
|
||||
echo " npm test - Run tests"
|
||||
else
|
||||
echo ""
|
||||
echo "❌ Failed to install dependencies"
|
||||
echo "Please check the error messages above"
|
||||
exit 1
|
||||
fi
|
||||
75
frontend/src/App.tsx
Normal file
75
frontend/src/App.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||
import GroceryList from './components/GroceryList';
|
||||
import ShopList from './components/ShopList';
|
||||
import ShoppingEventForm from './components/ShoppingEventForm';
|
||||
import ShoppingEventList from './components/ShoppingEventList';
|
||||
import Dashboard from './components/Dashboard';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white shadow-lg">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<h1 className="text-xl font-bold text-gray-800">
|
||||
🛒 Grocery Tracker
|
||||
</h1>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/groceries"
|
||||
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
|
||||
>
|
||||
Groceries
|
||||
</Link>
|
||||
<Link
|
||||
to="/shops"
|
||||
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
|
||||
>
|
||||
Shops
|
||||
</Link>
|
||||
<Link
|
||||
to="/shopping-events"
|
||||
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
|
||||
>
|
||||
Shopping Events
|
||||
</Link>
|
||||
<Link
|
||||
to="/add-purchase"
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white inline-flex items-center px-3 py-2 text-sm font-medium rounded-md"
|
||||
>
|
||||
Add Purchase
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/groceries" element={<GroceryList />} />
|
||||
<Route path="/shops" element={<ShopList />} />
|
||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||
<Route path="/add-purchase" element={<ShoppingEventForm />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
135
frontend/src/components/Dashboard.tsx
Normal file
135
frontend/src/components/Dashboard.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600">Welcome to your grocery tracker!</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Stats Cards */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-blue-100 rounded-md">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Total Shopping Events</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-green-100 rounded-md">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Total Spent</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">$-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-yellow-100 rounded-md">
|
||||
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Unique Items</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-purple-100 rounded-md">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Shops Visited</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium text-gray-900">Quick Actions</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div className="p-2 bg-blue-100 rounded-md mr-3">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Add Purchase</p>
|
||||
<p className="text-sm text-gray-600">Record a new shopping event</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div className="p-2 bg-green-100 rounded-md mr-3">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Add Grocery</p>
|
||||
<p className="text-sm text-gray-600">Add a new grocery item</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div className="p-2 bg-purple-100 rounded-md mr-3">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Add Shop</p>
|
||||
<p className="text-sm text-gray-600">Register a new shop</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium text-gray-900">Recent Shopping Events</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="text-center py-8">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No shopping events yet</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first purchase!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
142
frontend/src/components/GroceryList.tsx
Normal file
142
frontend/src/components/GroceryList.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Grocery } from '../types';
|
||||
import { groceryApi } from '../services/api';
|
||||
|
||||
const GroceryList: React.FC = () => {
|
||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroceries();
|
||||
}, []);
|
||||
|
||||
const fetchGroceries = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await groceryApi.getAll();
|
||||
setGroceries(response.data);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch groceries');
|
||||
console.error('Error fetching groceries:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Are you sure you want to delete this grocery item?')) {
|
||||
try {
|
||||
await groceryApi.delete(id);
|
||||
setGroceries(groceries.filter(g => g.id !== id));
|
||||
} catch (err) {
|
||||
setError('Failed to delete grocery');
|
||||
console.error('Error deleting grocery:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
|
||||
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Add New Grocery
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{groceries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery item.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Price
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Weight
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Organic
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{groceries.map((grocery) => (
|
||||
<tr key={grocery.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{grocery.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">${grocery.price.toFixed(2)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
{grocery.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
grocery.organic
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{grocery.organic ? 'Organic' : 'Conventional'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button className="text-indigo-600 hover:text-indigo-900 mr-3">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(grocery.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroceryList;
|
||||
109
frontend/src/components/ShopList.tsx
Normal file
109
frontend/src/components/ShopList.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Shop } from '../types';
|
||||
import { shopApi } from '../services/api';
|
||||
|
||||
const ShopList: React.FC = () => {
|
||||
const [shops, setShops] = useState<Shop[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchShops();
|
||||
}, []);
|
||||
|
||||
const fetchShops = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await shopApi.getAll();
|
||||
setShops(response.data);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch shops');
|
||||
console.error('Error fetching shops:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Shops</h1>
|
||||
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Add New Shop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{shops.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No shops</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first shop.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
||||
{shops.map((shop) => (
|
||||
<div key={shop.id} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">{shop.name}</h3>
|
||||
<div className="flex space-x-2">
|
||||
<button className="text-indigo-600 hover:text-indigo-900 text-sm">
|
||||
Edit
|
||||
</button>
|
||||
<button className="text-red-600 hover:text-red-900 text-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{shop.city}
|
||||
</div>
|
||||
|
||||
{shop.address && (
|
||||
<div className="flex items-start text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 7.89a2 2 0 002.83 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{shop.address}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Added {new Date(shop.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShopList;
|
||||
253
frontend/src/components/ShoppingEventForm.tsx
Normal file
253
frontend/src/components/ShoppingEventForm.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Shop, Grocery, ShoppingEventCreate, GroceryInEvent } from '../types';
|
||||
import { shopApi, groceryApi, shoppingEventApi } from '../services/api';
|
||||
|
||||
const ShoppingEventForm: React.FC = () => {
|
||||
const [shops, setShops] = useState<Shop[]>([]);
|
||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const [formData, setFormData] = useState<ShoppingEventCreate>({
|
||||
shop_id: 0,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
total_amount: undefined,
|
||||
notes: '',
|
||||
groceries: []
|
||||
});
|
||||
|
||||
const [selectedGroceries, setSelectedGroceries] = useState<GroceryInEvent[]>([]);
|
||||
const [newGroceryItem, setNewGroceryItem] = useState<GroceryInEvent>({
|
||||
grocery_id: 0,
|
||||
amount: 1
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchShops();
|
||||
fetchGroceries();
|
||||
}, []);
|
||||
|
||||
const fetchShops = async () => {
|
||||
try {
|
||||
const response = await shopApi.getAll();
|
||||
setShops(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching shops:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGroceries = async () => {
|
||||
try {
|
||||
const response = await groceryApi.getAll();
|
||||
setGroceries(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching groceries:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const addGroceryToEvent = () => {
|
||||
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0) {
|
||||
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]);
|
||||
setNewGroceryItem({ grocery_id: 0, amount: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
const removeGroceryFromEvent = (index: number) => {
|
||||
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const eventData = {
|
||||
...formData,
|
||||
groceries: selectedGroceries
|
||||
};
|
||||
|
||||
await shoppingEventApi.create(eventData);
|
||||
setMessage('Shopping event created successfully!');
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
shop_id: 0,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
total_amount: undefined,
|
||||
notes: '',
|
||||
groceries: []
|
||||
});
|
||||
setSelectedGroceries([]);
|
||||
} catch (error) {
|
||||
setMessage('Error creating shopping event. Please try again.');
|
||||
console.error('Error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getGroceryName = (id: number) => {
|
||||
const grocery = groceries.find(g => g.id === id);
|
||||
return grocery ? grocery.name : 'Unknown';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
Add New Purchase
|
||||
</h3>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
message.includes('Error')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Shop Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Shop
|
||||
</label>
|
||||
<select
|
||||
value={formData.shop_id}
|
||||
onChange={(e) => setFormData({...formData, shop_id: parseInt(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value={0}>Select a shop</option>
|
||||
{shops.map(shop => (
|
||||
<option key={shop.id} value={shop.id}>
|
||||
{shop.name} - {shop.city}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({...formData, date: e.target.value})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add Groceries Section */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Add Groceries
|
||||
</label>
|
||||
<div className="flex space-x-2 mb-4">
|
||||
<select
|
||||
value={newGroceryItem.grocery_id}
|
||||
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})}
|
||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value={0}>Select a grocery</option>
|
||||
{groceries.map(grocery => (
|
||||
<option key={grocery.id} value={grocery.id}>
|
||||
{grocery.name} - ${grocery.price} ({grocery.category})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
placeholder="Amount"
|
||||
value={newGroceryItem.amount}
|
||||
onChange={(e) => setNewGroceryItem({...newGroceryItem, amount: parseFloat(e.target.value)})}
|
||||
className="w-24 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addGroceryToEvent}
|
||||
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected Groceries List */}
|
||||
{selectedGroceries.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-md p-4">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
|
||||
{selectedGroceries.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
|
||||
<span>
|
||||
{getGroceryName(item.grocery_id)} x {item.amount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeGroceryFromEvent(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total Amount */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Total Amount (optional)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={formData.total_amount || ''}
|
||||
onChange={(e) => setFormData({...formData, total_amount: e.target.value ? parseFloat(e.target.value) : undefined})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({...formData, notes: e.target.value})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Any additional notes about this purchase..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || formData.shop_id === 0 || selectedGroceries.length === 0}
|
||||
className="w-full bg-blue-500 hover:bg-blue-700 disabled:bg-gray-300 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Shopping Event'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShoppingEventForm;
|
||||
123
frontend/src/components/ShoppingEventList.tsx
Normal file
123
frontend/src/components/ShoppingEventList.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ShoppingEvent } from '../types';
|
||||
import { shoppingEventApi } from '../services/api';
|
||||
|
||||
const ShoppingEventList: React.FC = () => {
|
||||
const [events, setEvents] = useState<ShoppingEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await shoppingEventApi.getAll();
|
||||
setEvents(response.data);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch shopping events');
|
||||
console.error('Error fetching events:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1>
|
||||
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Add New Event
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{events.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No shopping events</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by recording your first purchase.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 p-6">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">{event.shop.name}</h3>
|
||||
<p className="text-sm text-gray-600">{event.shop.city}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{new Date(event.date).toLocaleDateString()}
|
||||
</p>
|
||||
{event.total_amount && (
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
${event.total_amount.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.groceries.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Items Purchased:</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{event.groceries.map((grocery) => (
|
||||
<div key={grocery.id} className="bg-gray-50 rounded px-3 py-2">
|
||||
<span className="text-sm text-gray-900">{grocery.name}</span>
|
||||
<span className="text-xs text-gray-600 ml-2">${grocery.price}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.notes && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1">Notes:</h4>
|
||||
<p className="text-sm text-gray-600">{event.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-500">
|
||||
Event #{event.id} • {new Date(event.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<button className="text-indigo-600 hover:text-indigo-900">
|
||||
View Details
|
||||
</button>
|
||||
<button className="text-red-600 hover:text-red-900">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShoppingEventList;
|
||||
12
frontend/src/index.css
Normal file
12
frontend/src/index.css
Normal file
@ -0,0 +1,12 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
13
frontend/src/index.tsx
Normal file
13
frontend/src/index.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
50
frontend/src/services/api.ts
Normal file
50
frontend/src/services/api.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import axios from 'axios';
|
||||
import { Grocery, GroceryCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate } from '../types';
|
||||
|
||||
const BASE_URL = 'http://localhost:8000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Grocery API functions
|
||||
export const groceryApi = {
|
||||
getAll: () => api.get<Grocery[]>('/groceries/'),
|
||||
getById: (id: number) => api.get<Grocery>(`/groceries/${id}`),
|
||||
create: (grocery: GroceryCreate) => api.post<Grocery>('/groceries/', grocery),
|
||||
update: (id: number, grocery: Partial<GroceryCreate>) =>
|
||||
api.put<Grocery>(`/groceries/${id}`, grocery),
|
||||
delete: (id: number) => api.delete(`/groceries/${id}`),
|
||||
};
|
||||
|
||||
// Shop API functions
|
||||
export const shopApi = {
|
||||
getAll: () => api.get<Shop[]>('/shops/'),
|
||||
getById: (id: number) => api.get<Shop>(`/shops/${id}`),
|
||||
create: (shop: ShopCreate) => api.post<Shop>('/shops/', shop),
|
||||
update: (id: number, shop: Partial<ShopCreate>) =>
|
||||
api.put<Shop>(`/shops/${id}`, shop),
|
||||
delete: (id: number) => api.delete(`/shops/${id}`),
|
||||
};
|
||||
|
||||
// Shopping Event API functions
|
||||
export const shoppingEventApi = {
|
||||
getAll: () => api.get<ShoppingEvent[]>('/shopping-events/'),
|
||||
getById: (id: number) => api.get<ShoppingEvent>(`/shopping-events/${id}`),
|
||||
create: (event: ShoppingEventCreate) =>
|
||||
api.post<ShoppingEvent>('/shopping-events/', event),
|
||||
update: (id: number, event: Partial<ShoppingEventCreate>) =>
|
||||
api.put<ShoppingEvent>(`/shopping-events/${id}`, event),
|
||||
delete: (id: number) => api.delete(`/shopping-events/${id}`),
|
||||
};
|
||||
|
||||
// Statistics API functions
|
||||
export const statsApi = {
|
||||
getCategories: () => api.get('/stats/categories'),
|
||||
getShops: () => api.get('/stats/shops'),
|
||||
};
|
||||
|
||||
export default api;
|
||||
72
frontend/src/types/index.ts
Normal file
72
frontend/src/types/index.ts
Normal file
@ -0,0 +1,72 @@
|
||||
export interface Grocery {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
category: string;
|
||||
organic: boolean;
|
||||
weight?: number;
|
||||
weight_unit: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface GroceryCreate {
|
||||
name: string;
|
||||
price: number;
|
||||
category: string;
|
||||
organic: boolean;
|
||||
weight?: number;
|
||||
weight_unit: string;
|
||||
}
|
||||
|
||||
export interface Shop {
|
||||
id: number;
|
||||
name: string;
|
||||
city: string;
|
||||
address?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ShopCreate {
|
||||
name: string;
|
||||
city: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
export interface GroceryInEvent {
|
||||
grocery_id: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface ShoppingEvent {
|
||||
id: number;
|
||||
shop_id: number;
|
||||
date: string;
|
||||
total_amount?: number;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
shop: Shop;
|
||||
groceries: Grocery[];
|
||||
}
|
||||
|
||||
export interface ShoppingEventCreate {
|
||||
shop_id: number;
|
||||
date?: string;
|
||||
total_amount?: number;
|
||||
notes?: string;
|
||||
groceries: GroceryInEvent[];
|
||||
}
|
||||
|
||||
export interface CategoryStats {
|
||||
category: string;
|
||||
total_spent: number;
|
||||
item_count: number;
|
||||
avg_price: number;
|
||||
}
|
||||
|
||||
export interface ShopStats {
|
||||
shop_name: string;
|
||||
total_spent: number;
|
||||
visit_count: number;
|
||||
avg_per_visit: number;
|
||||
}
|
||||
10
frontend/tailwind.config.js
Normal file
10
frontend/tailwind.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es6"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
15
package.json
Normal file
15
package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "grocery-tracker-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "React frontend for grocery price tracking application",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "cd frontend && npm run dev",
|
||||
"build": "cd frontend && npm run build",
|
||||
"install:frontend": "cd frontend && npm install",
|
||||
"setup": "npm run install:frontend"
|
||||
},
|
||||
"keywords": ["grocery", "price-tracking", "shopping", "react", "fastapi", "python"],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user