first commit
This commit is contained in:
commit
3d1d48fd49
31
.github/copilot-instructions.md
vendored
Normal file
31
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# Wedding Guest List App - Project Setup
|
||||
|
||||
## Project Overview
|
||||
Full-stack wedding guest list application with React + Vite frontend, FastAPI backend, and PostgreSQL database.
|
||||
|
||||
## Features
|
||||
- Manage guest information (names, phone numbers, email, RSVP status, meal preferences, plus-ones)
|
||||
- Import contacts from Google account
|
||||
- Filter and search guests
|
||||
- User-friendly interface with modern design
|
||||
|
||||
## Setup Status
|
||||
- [x] Create copilot-instructions.md file
|
||||
- [x] Get project setup info
|
||||
- [x] Create backend structure (FastAPI + SQLAlchemy + PostgreSQL)
|
||||
- [x] Create frontend structure (React + Vite)
|
||||
- [x] Add Google Contacts integration
|
||||
- [x] Create documentation (README.md)
|
||||
- [x] Install dependencies and test
|
||||
|
||||
## ✅ Project Complete!
|
||||
|
||||
All components have been created and dependencies installed.
|
||||
|
||||
### Next Steps:
|
||||
1. Set up PostgreSQL database
|
||||
2. Create .env file from .env.example
|
||||
3. Configure Google OAuth (optional)
|
||||
4. Run `start.bat` (Windows) or `./start.sh` (Linux/Mac)
|
||||
|
||||
See README.md for detailed instructions.
|
||||
278
README.md
Normal file
278
README.md
Normal file
@ -0,0 +1,278 @@
|
||||
# 💒 Wedding Guest List App
|
||||
|
||||
A full-stack wedding guest list management application built with React + Vite frontend, FastAPI backend, and PostgreSQL database. Easily manage your wedding guests, import contacts from Google, and track RSVPs, meal preferences, and seating arrangements.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Guest Management**: Add, edit, and delete guest information
|
||||
- **Contact Import**: Import contacts directly from your Google account
|
||||
- **Search & Filter**: Find guests by name, email, phone, RSVP status, or meal preference
|
||||
- **RSVP Tracking**: Track pending, accepted, and declined RSVPs
|
||||
- **Meal Preferences**: Manage dietary requirements (vegetarian, vegan, gluten-free, kosher, halal)
|
||||
- **Plus-Ones**: Track guests with plus-ones
|
||||
- **Table Assignments**: Assign guests to tables
|
||||
- **Modern UI**: User-friendly interface with a beautiful gradient design
|
||||
- **Mobile Responsive**: Works seamlessly on all devices
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
### Frontend
|
||||
- React 18
|
||||
- Vite
|
||||
- Axios for API calls
|
||||
- CSS3 with modern styling
|
||||
|
||||
### Backend
|
||||
- FastAPI
|
||||
- SQLAlchemy ORM
|
||||
- PostgreSQL database
|
||||
- Google People API integration
|
||||
- Pydantic for data validation
|
||||
|
||||
## 📋 Prerequisites
|
||||
|
||||
- **Node.js** (v16 or higher)
|
||||
- **Python** (v3.8 or higher)
|
||||
- **PostgreSQL** (v12 or higher)
|
||||
- **Google Cloud Console** account (for contact import feature)
|
||||
|
||||
## 🚀 Installation & Setup
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
cd c:\Users\dvirl\OneDrive\Desktop\gitea\invy
|
||||
```
|
||||
|
||||
### 2. Database Setup
|
||||
|
||||
Install PostgreSQL and create a database:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE wedding_guests;
|
||||
```
|
||||
|
||||
### 3. Backend Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Create virtual environment (optional but recommended)
|
||||
python -m venv venv
|
||||
|
||||
# Activate virtual environment
|
||||
# Windows:
|
||||
venv\Scripts\activate
|
||||
# Linux/Mac:
|
||||
source venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Create .env file
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
Edit the `.env` file with your database credentials:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://postgres:your_password@localhost:5432/wedding_guests
|
||||
```
|
||||
|
||||
### 4. Frontend Setup
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
```
|
||||
|
||||
### 5. Google OAuth Setup (Optional - for contact import)
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Create a new project or select an existing one
|
||||
3. Enable the **Google People API**
|
||||
4. Create OAuth 2.0 credentials:
|
||||
- Application type: Web application
|
||||
- Authorized JavaScript origins: `http://localhost:5173`
|
||||
- Authorized redirect URIs: `http://localhost:5173`
|
||||
5. Copy the Client ID
|
||||
6. Update `GOOGLE_CLIENT_ID` in `frontend/src/components/GoogleImport.jsx`
|
||||
|
||||
## 🎯 Running the Application
|
||||
|
||||
### Start the Backend Server
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
The API will be available at: `http://localhost:8000`
|
||||
|
||||
API documentation: `http://localhost:8000/docs`
|
||||
|
||||
### Start the Frontend Development Server
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The application will be available at: `http://localhost:5173`
|
||||
|
||||
## 📖 API Documentation
|
||||
|
||||
Once the backend is running, visit `http://localhost:8000/docs` for interactive API documentation powered by Swagger UI.
|
||||
|
||||
### Main Endpoints
|
||||
|
||||
- `GET /guests/` - Get all guests
|
||||
- `POST /guests/` - Create a new guest
|
||||
- `GET /guests/{guest_id}` - Get a specific guest
|
||||
- `PUT /guests/{guest_id}` - Update a guest
|
||||
- `DELETE /guests/{guest_id}` - Delete a guest
|
||||
- `GET /guests/search/` - Search and filter guests
|
||||
- `POST /import/google` - Import contacts from Google
|
||||
|
||||
## 📱 Usage Guide
|
||||
|
||||
### Adding Guests Manually
|
||||
|
||||
1. Click the **"+ Add Guest"** button
|
||||
2. Fill in the guest details:
|
||||
- First and last name (required)
|
||||
- Email and phone number (optional)
|
||||
- RSVP status
|
||||
- Meal preference
|
||||
- Plus-one information
|
||||
- Table number
|
||||
- Notes
|
||||
3. Click **"Add Guest"** to save
|
||||
|
||||
### Importing from Google Contacts
|
||||
|
||||
1. Click the **"Import from Google"** button
|
||||
2. Sign in with your Google account
|
||||
3. Grant permission to access your contacts
|
||||
4. Contacts will be imported automatically
|
||||
|
||||
### Searching and Filtering
|
||||
|
||||
Use the search bar and filters to find guests:
|
||||
- Search by name, email, or phone number
|
||||
- Filter by RSVP status (pending, accepted, declined)
|
||||
- Filter by meal preference
|
||||
- Click **"Clear Filters"** to reset
|
||||
|
||||
### Editing and Deleting Guests
|
||||
|
||||
- Click **"Edit"** on any guest row to modify their information
|
||||
- Click **"Delete"** to remove a guest (confirmation required)
|
||||
|
||||
## 🗂️ Project Structure
|
||||
|
||||
```
|
||||
wedding-guest-list/
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI application entry point
|
||||
│ ├── models.py # SQLAlchemy database models
|
||||
│ ├── schemas.py # Pydantic schemas for validation
|
||||
│ ├── crud.py # Database operations
|
||||
│ ├── database.py # Database configuration
|
||||
│ ├── google_contacts.py # Google API integration
|
||||
│ ├── requirements.txt # Python dependencies
|
||||
│ └── .env.example # Environment variables template
|
||||
│
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React components
|
||||
│ │ │ ├── GuestList.jsx
|
||||
│ │ │ ├── GuestForm.jsx
|
||||
│ │ │ ├── SearchFilter.jsx
|
||||
│ │ │ └── GoogleImport.jsx
|
||||
│ │ ├── api/ # API client
|
||||
│ │ │ └── api.js
|
||||
│ │ ├── App.jsx # Main app component
|
||||
│ │ └── main.jsx # Application entry point
|
||||
│ ├── index.html
|
||||
│ ├── package.json
|
||||
│ └── vite.config.js
|
||||
│
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🔒 Security Notes
|
||||
|
||||
- Never commit your `.env` file or Google OAuth credentials
|
||||
- Use environment variables for sensitive data
|
||||
- In production, use HTTPS for all communications
|
||||
- Implement proper authentication and authorization
|
||||
- Validate all user inputs on both frontend and backend
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
### Backend Deployment
|
||||
|
||||
Consider deploying to:
|
||||
- **Heroku** (with PostgreSQL add-on)
|
||||
- **Railway**
|
||||
- **Render**
|
||||
- **AWS EC2 + RDS**
|
||||
|
||||
### Frontend Deployment
|
||||
|
||||
Consider deploying to:
|
||||
- **Vercel**
|
||||
- **Netlify**
|
||||
- **GitHub Pages**
|
||||
|
||||
Don't forget to update:
|
||||
- API base URL in `frontend/src/api/api.js`
|
||||
- CORS settings in `backend/main.py`
|
||||
- Google OAuth redirect URIs
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Feel free to:
|
||||
- Report bugs
|
||||
- Suggest new features
|
||||
- Submit pull requests
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is open source and available under the MIT License.
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
- Regularly backup your database
|
||||
- Test the Google import feature with a test account first
|
||||
- Use the table assignment feature to organize seating
|
||||
- Export guest data before the wedding day
|
||||
- Keep track of dietary restrictions for catering
|
||||
|
||||
## 🆘 Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
- Verify PostgreSQL is running
|
||||
- Check database credentials in `.env`
|
||||
- Ensure the database exists
|
||||
|
||||
### Google Import Not Working
|
||||
- Verify OAuth credentials are correct
|
||||
- Check that Google People API is enabled
|
||||
- Ensure redirect URI matches exactly
|
||||
|
||||
### Frontend Can't Connect to Backend
|
||||
- Verify backend is running on port 8000
|
||||
- Check CORS settings in `backend/main.py`
|
||||
- Ensure API URL is correct in `frontend/src/api/api.js`
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For questions or issues, please open an issue on the GitHub repository.
|
||||
|
||||
---
|
||||
|
||||
Made with ❤️ for your special day!
|
||||
6
backend/.env.example
Normal file
6
backend/.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgresql://wedding_admin:Aa123456@localhost:5432/wedding_guests
|
||||
|
||||
# Google OAuth (for contact import)
|
||||
GOOGLE_CLIENT_ID=143092846986-v9s70im3ilpai78n89q38qpikernp2f8.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=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
|
||||
34
backend/.gitignore
vendored
Normal file
34
backend/.gitignore
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
110
backend/crud.py
Normal file
110
backend/crud.py
Normal file
@ -0,0 +1,110 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
import models
|
||||
import schemas
|
||||
|
||||
|
||||
def get_guest(db: Session, guest_id: int):
|
||||
return db.query(models.Guest).filter(models.Guest.id == guest_id).first()
|
||||
|
||||
|
||||
def get_guests(db: Session, skip: int = 0, limit: int = 100):
|
||||
return db.query(models.Guest).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def create_guest(db: Session, guest: schemas.GuestCreate):
|
||||
db_guest = models.Guest(**guest.model_dump())
|
||||
db.add(db_guest)
|
||||
db.commit()
|
||||
db.refresh(db_guest)
|
||||
return db_guest
|
||||
|
||||
|
||||
def update_guest(db: Session, guest_id: int, guest: schemas.GuestUpdate):
|
||||
db_guest = db.query(models.Guest).filter(models.Guest.id == guest_id).first()
|
||||
if db_guest:
|
||||
update_data = guest.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_guest, field, value)
|
||||
db.commit()
|
||||
db.refresh(db_guest)
|
||||
return db_guest
|
||||
|
||||
|
||||
def delete_guest(db: Session, guest_id: int):
|
||||
db_guest = db.query(models.Guest).filter(models.Guest.id == guest_id).first()
|
||||
if db_guest:
|
||||
db.delete(db_guest)
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def search_guests(
|
||||
db: Session,
|
||||
query: str = "",
|
||||
rsvp_status: str = None,
|
||||
meal_preference: str = None,
|
||||
owner: str = None
|
||||
):
|
||||
db_query = db.query(models.Guest)
|
||||
|
||||
# Search by name, email, or phone
|
||||
if query:
|
||||
search_pattern = f"%{query}%"
|
||||
db_query = db_query.filter(
|
||||
or_(
|
||||
models.Guest.first_name.ilike(search_pattern),
|
||||
models.Guest.last_name.ilike(search_pattern),
|
||||
models.Guest.email.ilike(search_pattern),
|
||||
models.Guest.phone_number.ilike(search_pattern)
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by RSVP status
|
||||
if rsvp_status:
|
||||
db_query = db_query.filter(models.Guest.rsvp_status == rsvp_status)
|
||||
|
||||
# Filter by meal preference
|
||||
if meal_preference:
|
||||
db_query = db_query.filter(models.Guest.meal_preference == meal_preference)
|
||||
|
||||
# Filter by owner
|
||||
if owner:
|
||||
db_query = db_query.filter(models.Guest.owner == owner)
|
||||
|
||||
return db_query.all()
|
||||
|
||||
|
||||
def delete_guests_bulk(db: Session, guest_ids: list[int]):
|
||||
"""Delete multiple guests by their IDs"""
|
||||
deleted_count = db.query(models.Guest).filter(models.Guest.id.in_(guest_ids)).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return deleted_count
|
||||
|
||||
|
||||
def delete_guests_by_owner(db: Session, owner: str):
|
||||
"""Delete all guests by owner (for undo import)"""
|
||||
# Delete guests where owner matches exactly or is in comma-separated list
|
||||
deleted_count = db.query(models.Guest).filter(
|
||||
or_(
|
||||
models.Guest.owner == owner,
|
||||
models.Guest.owner.like(f"{owner},%"),
|
||||
models.Guest.owner.like(f"%,{owner},%"),
|
||||
models.Guest.owner.like(f"%,{owner}")
|
||||
)
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return deleted_count
|
||||
|
||||
|
||||
def get_unique_owners(db: Session):
|
||||
"""Get list of unique owner emails"""
|
||||
results = db.query(models.Guest.owner).distinct().filter(models.Guest.owner.isnot(None)).all()
|
||||
owners = set()
|
||||
for result in results:
|
||||
if result[0]:
|
||||
# Split comma-separated owners
|
||||
for owner in result[0].split(','):
|
||||
owners.add(owner.strip())
|
||||
return sorted(list(owners))
|
||||
23
backend/database.py
Normal file
23
backend/database.py
Normal file
@ -0,0 +1,23 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
# Database URL - update with your PostgreSQL credentials
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://wedding_admin:Aa123456@localhost:5432/wedding_guests"
|
||||
)
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
89
backend/google_contacts.py
Normal file
89
backend/google_contacts.py
Normal file
@ -0,0 +1,89 @@
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
import models
|
||||
|
||||
|
||||
async def import_contacts_from_google(access_token: str, db: Session, owner: str = None) -> int:
|
||||
"""
|
||||
Import contacts from Google People API
|
||||
|
||||
Args:
|
||||
access_token: OAuth 2.0 access token from Google
|
||||
db: Database session
|
||||
owner: Name of the person importing (e.g., 'me', 'fianc\u00e9')
|
||||
|
||||
Returns:
|
||||
Number of contacts imported
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}
|
||||
|
||||
# Google People API endpoint
|
||||
url = "https://people.googleapis.com/v1/people/me/connections"
|
||||
params = {
|
||||
"personFields": "names,phoneNumbers,emailAddresses",
|
||||
"pageSize": 1000
|
||||
}
|
||||
|
||||
imported_count = 0
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, headers=headers, params=params)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Failed to fetch contacts: {response.text}")
|
||||
|
||||
data = response.json()
|
||||
connections = data.get("connections", [])
|
||||
|
||||
for connection in connections:
|
||||
# Extract name
|
||||
names = connection.get("names", [])
|
||||
if not names:
|
||||
continue
|
||||
|
||||
name = names[0]
|
||||
first_name = name.get("givenName", "")
|
||||
last_name = name.get("familyName", "")
|
||||
|
||||
if not first_name and not last_name:
|
||||
continue
|
||||
|
||||
# Extract email
|
||||
emails = connection.get("emailAddresses", [])
|
||||
email = emails[0].get("value") if emails else None
|
||||
|
||||
# Extract phone number
|
||||
phones = connection.get("phoneNumbers", [])
|
||||
phone_number = phones[0].get("value") if phones else None
|
||||
|
||||
# Check if contact already exists by email OR phone number
|
||||
existing = None
|
||||
if email:
|
||||
existing = db.query(models.Guest).filter(models.Guest.email == email).first()
|
||||
if not existing and phone_number:
|
||||
existing = db.query(models.Guest).filter(models.Guest.phone_number == phone_number).first()
|
||||
|
||||
if existing:
|
||||
# Contact exists - merge owners
|
||||
if existing.owner and owner not in existing.owner.split(","):
|
||||
# Add current owner to existing owners
|
||||
existing.owner = f"{existing.owner},{owner}"
|
||||
db.add(existing)
|
||||
else:
|
||||
# Create new guest
|
||||
guest = models.Guest(
|
||||
first_name=first_name or "Unknown",
|
||||
last_name=last_name or "",
|
||||
email=email,
|
||||
phone_number=phone_number,
|
||||
rsvp_status="pending",
|
||||
owner=owner
|
||||
)
|
||||
db.add(guest)
|
||||
imported_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return imported_count
|
||||
193
backend/main.py
Normal file
193
backend/main.py
Normal file
@ -0,0 +1,193 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
import uvicorn
|
||||
from typing import List
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
import httpx
|
||||
import models
|
||||
import schemas
|
||||
import crud
|
||||
from database import engine, get_db
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Create database tables
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(title="Wedding Guest List API")
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"], # Vite default port
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Wedding Guest List API"}
|
||||
|
||||
|
||||
# Guest endpoints
|
||||
@app.post("/guests/", response_model=schemas.Guest)
|
||||
def create_guest(guest: schemas.GuestCreate, db: Session = Depends(get_db)):
|
||||
return crud.create_guest(db=db, guest=guest)
|
||||
|
||||
|
||||
@app.get("/guests/", response_model=List[schemas.Guest])
|
||||
def read_guests(skip: int = 0, limit: int = 1000, db: Session = Depends(get_db)):
|
||||
guests = crud.get_guests(db, skip=skip, limit=limit)
|
||||
return guests
|
||||
|
||||
|
||||
@app.get("/guests/{guest_id}", response_model=schemas.Guest)
|
||||
def read_guest(guest_id: int, db: Session = Depends(get_db)):
|
||||
db_guest = crud.get_guest(db, guest_id=guest_id)
|
||||
if db_guest is None:
|
||||
raise HTTPException(status_code=404, detail="Guest not found")
|
||||
return db_guest
|
||||
|
||||
|
||||
@app.put("/guests/{guest_id}", response_model=schemas.Guest)
|
||||
def update_guest(guest_id: int, guest: schemas.GuestUpdate, db: Session = Depends(get_db)):
|
||||
db_guest = crud.update_guest(db, guest_id=guest_id, guest=guest)
|
||||
if db_guest is None:
|
||||
raise HTTPException(status_code=404, detail="Guest not found")
|
||||
return db_guest
|
||||
|
||||
|
||||
@app.delete("/guests/{guest_id}")
|
||||
def delete_guest(guest_id: int, db: Session = Depends(get_db)):
|
||||
success = crud.delete_guest(db, guest_id=guest_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Guest not found")
|
||||
return {"message": "Guest deleted successfully"}
|
||||
|
||||
|
||||
@app.post("/guests/bulk-delete")
|
||||
def delete_guests_bulk(guest_ids: List[int], db: Session = Depends(get_db)):
|
||||
deleted_count = crud.delete_guests_bulk(db, guest_ids=guest_ids)
|
||||
return {"message": f"Successfully deleted {deleted_count} guests"}
|
||||
|
||||
|
||||
@app.delete("/guests/undo-import/{owner}")
|
||||
def undo_import(owner: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Delete all guests imported by a specific owner
|
||||
"""
|
||||
deleted_count = crud.delete_guests_by_owner(db, owner=owner)
|
||||
return {"message": f"Successfully deleted {deleted_count} guests from {owner}"}
|
||||
|
||||
|
||||
@app.get("/guests/owners/")
|
||||
def get_owners(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get list of unique owners
|
||||
"""
|
||||
owners = crud.get_unique_owners(db)
|
||||
return {"owners": owners}
|
||||
|
||||
|
||||
# Search and filter endpoints
|
||||
@app.get("/guests/search/", response_model=List[schemas.Guest])
|
||||
def search_guests(
|
||||
query: str = "",
|
||||
rsvp_status: str = None,
|
||||
meal_preference: str = None,
|
||||
owner: str = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
guests = crud.search_guests(
|
||||
db, query=query, rsvp_status=rsvp_status, meal_preference=meal_preference, owner=owner
|
||||
)
|
||||
return guests
|
||||
|
||||
|
||||
# Google OAuth configuration
|
||||
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
|
||||
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
|
||||
GOOGLE_REDIRECT_URI = "http://localhost:8000/auth/google/callback"
|
||||
|
||||
# Google OAuth endpoints
|
||||
@app.get("/auth/google")
|
||||
async def google_auth():
|
||||
"""
|
||||
Initiate Google OAuth flow - redirects to Google
|
||||
"""
|
||||
auth_url = (
|
||||
f"https://accounts.google.com/o/oauth2/v2/auth?"
|
||||
f"client_id={GOOGLE_CLIENT_ID}&"
|
||||
f"redirect_uri={GOOGLE_REDIRECT_URI}&"
|
||||
f"response_type=code&"
|
||||
f"scope=https://www.googleapis.com/auth/contacts.readonly%20https://www.googleapis.com/auth/userinfo.email&"
|
||||
f"access_type=offline&"
|
||||
f"prompt=consent"
|
||||
)
|
||||
return RedirectResponse(url=auth_url)
|
||||
|
||||
|
||||
@app.get("/auth/google/callback")
|
||||
async def google_callback(code: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Handle Google OAuth callback and import contacts
|
||||
Owner will be extracted from the user's email
|
||||
"""
|
||||
try:
|
||||
# Exchange code for access token
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": GOOGLE_CLIENT_ID,
|
||||
"client_secret": GOOGLE_CLIENT_SECRET,
|
||||
"redirect_uri": GOOGLE_REDIRECT_URI,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail="Failed to get access token")
|
||||
|
||||
token_data = token_response.json()
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
# Get user info to extract email
|
||||
user_info_response = await client.get(
|
||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
|
||||
if user_info_response.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail="Failed to get user info")
|
||||
|
||||
user_info = user_info_response.json()
|
||||
user_email = user_info.get("email", "unknown")
|
||||
# Use full email as owner
|
||||
owner = user_email
|
||||
|
||||
# Import contacts
|
||||
from google_contacts import import_contacts_from_google
|
||||
imported_count = await import_contacts_from_google(access_token, db, owner)
|
||||
|
||||
# Redirect back to frontend with success message
|
||||
return RedirectResponse(
|
||||
url=f"http://localhost:5173?imported={imported_count}&owner={owner}",
|
||||
status_code=302
|
||||
)
|
||||
except Exception as e:
|
||||
# Redirect back with error
|
||||
return RedirectResponse(
|
||||
url=f"http://localhost:5173?error={str(e)}",
|
||||
status_code=302
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
34
backend/models.py
Normal file
34
backend/models.py
Normal file
@ -0,0 +1,34 @@
|
||||
from sqlalchemy import Boolean, Column, Integer, String, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
|
||||
|
||||
class Guest(Base):
|
||||
__tablename__ = "guests"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
first_name = Column(String, nullable=False)
|
||||
last_name = Column(String, nullable=False)
|
||||
email = Column(String, unique=True, index=True)
|
||||
phone_number = Column(String)
|
||||
|
||||
# RSVP status: pending, accepted, declined
|
||||
rsvp_status = Column(String, default="pending")
|
||||
|
||||
# Meal preferences
|
||||
meal_preference = Column(String) # vegetarian, vegan, gluten-free, no-preference, etc.
|
||||
|
||||
# Plus one information
|
||||
has_plus_one = Column(Boolean, default=False)
|
||||
plus_one_name = Column(String, nullable=True)
|
||||
|
||||
# Owner tracking (who added this guest)
|
||||
owner = Column(String, nullable=True) # e.g., 'me', 'fiancé', or specific name
|
||||
|
||||
# Additional notes
|
||||
notes = Column(String, nullable=True)
|
||||
table_number = Column(Integer, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
fastapi>=0.104.1
|
||||
uvicorn[standard]>=0.24.0
|
||||
sqlalchemy>=2.0.23
|
||||
psycopg2-binary>=2.9.9
|
||||
pydantic[email]>=2.5.0
|
||||
httpx>=0.25.2
|
||||
python-dotenv>=1.0.0
|
||||
105
backend/schema.sql
Normal file
105
backend/schema.sql
Normal file
@ -0,0 +1,105 @@
|
||||
-- Wedding Guest List Database Schema
|
||||
-- PostgreSQL Database Setup Script
|
||||
|
||||
-- ============================================
|
||||
-- STEP 1: Create Database User
|
||||
-- ============================================
|
||||
-- Create a dedicated user for the wedding guest list application
|
||||
-- Change the password to something secure!
|
||||
CREATE USER wedding_admin WITH PASSWORD 'your_secure_password_here';
|
||||
|
||||
-- Grant user the ability to create databases (optional)
|
||||
ALTER USER wedding_admin CREATEDB;
|
||||
|
||||
-- ============================================
|
||||
-- STEP 2: Create Database
|
||||
-- ============================================
|
||||
-- Create the wedding guests database
|
||||
CREATE DATABASE wedding_guests
|
||||
WITH
|
||||
OWNER = wedding_admin
|
||||
ENCODING = 'UTF8'
|
||||
LC_COLLATE = 'en_US.UTF-8'
|
||||
LC_CTYPE = 'en_US.UTF-8'
|
||||
TEMPLATE = template0;
|
||||
|
||||
-- Connect to the new database
|
||||
\c wedding_guests
|
||||
|
||||
-- ============================================
|
||||
-- STEP 3: Grant Schema Privileges
|
||||
-- ============================================
|
||||
-- Grant all privileges on the public schema to the user
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO wedding_admin;
|
||||
|
||||
-- Grant privileges on all current and future tables
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO wedding_admin;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO wedding_admin;
|
||||
|
||||
-- Ensure the user has privileges on future objects
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO wedding_admin;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO wedding_admin;
|
||||
|
||||
-- ============================================
|
||||
-- STEP 4: Create Tables
|
||||
-- ============================================
|
||||
|
||||
-- Guests table
|
||||
CREATE TABLE IF NOT EXISTS guests (
|
||||
id SERIAL PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) UNIQUE,
|
||||
phone_number VARCHAR(50),
|
||||
rsvp_status VARCHAR(20) DEFAULT 'pending' CHECK (rsvp_status IN ('pending', 'accepted', 'declined')),
|
||||
meal_preference VARCHAR(50),
|
||||
has_plus_one BOOLEAN DEFAULT FALSE,
|
||||
plus_one_name VARCHAR(200),
|
||||
owner VARCHAR(50),
|
||||
notes TEXT,
|
||||
table_number INTEGER,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX idx_guests_email ON guests(email);
|
||||
CREATE INDEX idx_guests_rsvp_status ON guests(rsvp_status);
|
||||
CREATE INDEX idx_guests_last_name ON guests(last_name);
|
||||
CREATE INDEX idx_guests_owner ON guests(owner);
|
||||
|
||||
-- Create a trigger to automatically update the updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_guests_updated_at
|
||||
BEFORE UPDATE ON guests
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Grant table ownership to wedding_admin
|
||||
ALTER TABLE guests OWNER TO wedding_admin;
|
||||
|
||||
-- ============================================
|
||||
-- STEP 5: Insert Sample Data (Optional)
|
||||
-- ============================================
|
||||
-- Uncomment the lines below to insert sample guests for testing
|
||||
|
||||
-- INSERT INTO guests (first_name, last_name, email, phone_number, rsvp_status, meal_preference, has_plus_one)
|
||||
-- VALUES
|
||||
-- ('John', 'Doe', 'john.doe@example.com', '+1-555-0101', 'accepted', 'vegetarian', TRUE),
|
||||
-- ('Jane', 'Smith', 'jane.smith@example.com', '+1-555-0102', 'pending', 'vegan', FALSE),
|
||||
-- ('Bob', 'Johnson', 'bob.j@example.com', '+1-555-0103', 'accepted', 'gluten-free', TRUE),
|
||||
-- ('Alice', 'Williams', 'alice.w@example.com', '+1-555-0104', 'declined', NULL, FALSE);
|
||||
|
||||
-- ============================================
|
||||
-- Verification Query
|
||||
-- ============================================
|
||||
-- Run this to verify the setup
|
||||
SELECT 'Database setup completed successfully!' AS status;
|
||||
SELECT COUNT(*) AS total_guests FROM guests;
|
||||
35
backend/schemas.py
Normal file
35
backend/schemas.py
Normal file
@ -0,0 +1,35 @@
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class GuestBase(BaseModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: Optional[EmailStr] = None
|
||||
phone_number: Optional[str] = None
|
||||
rsvp_status: str = "pending"
|
||||
meal_preference: Optional[str] = None
|
||||
has_plus_one: bool = False
|
||||
plus_one_name: Optional[str] = None
|
||||
owner: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
table_number: Optional[int] = None
|
||||
|
||||
|
||||
class GuestCreate(GuestBase):
|
||||
pass
|
||||
|
||||
|
||||
class GuestUpdate(GuestBase):
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
|
||||
|
||||
class Guest(GuestBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
30
frontend/.gitignore
vendored
Normal file
30
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Vite
|
||||
.vite/
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wedding Guest List</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1946
frontend/package-lock.json
generated
Normal file
1946
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "wedding-guest-list-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"axios": "^1.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
17
frontend/public/callback.html
Normal file
17
frontend/public/callback.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Google OAuth Callback</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// Send the token back to the parent window
|
||||
if (window.opener) {
|
||||
window.opener.postMessage(window.location.hash, window.location.origin);
|
||||
window.close();
|
||||
} else {
|
||||
document.write('Authentication successful! You can close this window.');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
96
frontend/src/App.css
Normal file
96
frontend/src/App.css
Normal file
@ -0,0 +1,96 @@
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 25px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #6b7280;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
99
frontend/src/App.jsx
Normal file
99
frontend/src/App.jsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import GuestList from './components/GuestList'
|
||||
import GuestForm from './components/GuestForm'
|
||||
import SearchFilter from './components/SearchFilter'
|
||||
import GoogleImport from './components/GoogleImport'
|
||||
import { getGuests, searchGuests } from './api/api'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [guests, setGuests] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingGuest, setEditingGuest] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadGuests()
|
||||
}, [])
|
||||
|
||||
const loadGuests = async () => {
|
||||
try {
|
||||
const data = await getGuests()
|
||||
setGuests(data)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
console.error('Error loading guests:', error)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async (filters) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await searchGuests(filters)
|
||||
setGuests(data)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
console.error('Error searching guests:', error)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddGuest = () => {
|
||||
setEditingGuest(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleEditGuest = (guest) => {
|
||||
setEditingGuest(guest)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleFormClose = () => {
|
||||
setShowForm(false)
|
||||
setEditingGuest(null)
|
||||
loadGuests()
|
||||
}
|
||||
|
||||
const handleImportComplete = () => {
|
||||
loadGuests()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<header>
|
||||
<h1>💒 Wedding Guest List</h1>
|
||||
</header>
|
||||
|
||||
<div className="container">
|
||||
<div className="actions-bar">
|
||||
<button className="btn btn-primary" onClick={handleAddGuest}>
|
||||
+ Add Guest
|
||||
</button>
|
||||
<GoogleImport onImportComplete={handleImportComplete} />
|
||||
</div>
|
||||
|
||||
<SearchFilter onSearch={handleSearch} />
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading guests...</div>
|
||||
) : (
|
||||
<GuestList
|
||||
guests={guests}
|
||||
onEdit={handleEditGuest}
|
||||
onUpdate={loadGuests}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<GuestForm
|
||||
guest={editingGuest}
|
||||
onClose={handleFormClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
71
frontend/src/api/api.js
Normal file
71
frontend/src/api/api.js
Normal file
@ -0,0 +1,71 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Guest API calls
|
||||
export const getGuests = async () => {
|
||||
const response = await api.get('/guests/')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getGuest = async (id) => {
|
||||
const response = await api.get(`/guests/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createGuest = async (guest) => {
|
||||
const response = await api.post('/guests/', guest)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const updateGuest = async (id, guest) => {
|
||||
const response = await api.put(`/guests/${id}`, guest)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const deleteGuest = async (id) => {
|
||||
const response = await api.delete(`/guests/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const deleteGuestsBulk = async (guestIds) => {
|
||||
const response = await api.post('/guests/bulk-delete', guestIds)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const undoImport = async (owner) => {
|
||||
const response = await api.delete(`/guests/undo-import/${encodeURIComponent(owner)}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getOwners = async () => {
|
||||
const response = await api.get('/guests/owners/')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const searchGuests = async ({ query = '', rsvpStatus = '', mealPreference = '', owner = '' }) => {
|
||||
const params = new URLSearchParams()
|
||||
if (query) params.append('query', query)
|
||||
if (rsvpStatus) params.append('rsvp_status', rsvpStatus)
|
||||
if (mealPreference) params.append('meal_preference', mealPreference)
|
||||
if (owner) params.append('owner', owner)
|
||||
|
||||
const response = await api.get(`/guests/search/?${params.toString()}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const importGoogleContacts = async (accessToken) => {
|
||||
const response = await api.post('/import/google', null, {
|
||||
params: { access_token: accessToken }
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export default api
|
||||
28
frontend/src/components/GoogleImport.css
Normal file
28
frontend/src/components/GoogleImport.css
Normal file
@ -0,0 +1,28 @@
|
||||
.btn-google {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: white;
|
||||
color: #5f6368;
|
||||
border: 1px solid #dadce0;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-google:hover:not(:disabled) {
|
||||
background: #f8f9fa;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-google:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-google svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
69
frontend/src/components/GoogleImport.jsx
Normal file
69
frontend/src/components/GoogleImport.jsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import './GoogleImport.css'
|
||||
|
||||
function GoogleImport({ onImportComplete }) {
|
||||
const [importing, setImporting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we got redirected back from Google OAuth
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const imported = urlParams.get('imported')
|
||||
const importOwner = urlParams.get('owner')
|
||||
const error = urlParams.get('error')
|
||||
|
||||
if (imported) {
|
||||
alert(`Successfully imported ${imported} contacts from ${importOwner}'s Google account!`)
|
||||
onImportComplete()
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
alert(`Failed to import contacts: ${error}`)
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}
|
||||
}, [onImportComplete])
|
||||
|
||||
const handleGoogleImport = () => {
|
||||
setImporting(true)
|
||||
// Redirect to backend OAuth endpoint (owner will be extracted from email)
|
||||
window.location.href = 'http://localhost:8000/auth/google'
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn btn-google"
|
||||
onClick={handleGoogleImport}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? (
|
||||
'⏳ Importing...'
|
||||
) : (
|
||||
<>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Import from Google
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default GoogleImport
|
||||
133
frontend/src/components/GuestForm.css
Normal file
133
frontend/src/components/GuestForm.css
Normal file
@ -0,0 +1,133 @@
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 30px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 32px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 15px 20px;
|
||||
}
|
||||
}
|
||||
192
frontend/src/components/GuestForm.jsx
Normal file
192
frontend/src/components/GuestForm.jsx
Normal file
@ -0,0 +1,192 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createGuest, updateGuest } from '../api/api'
|
||||
import './GuestForm.css'
|
||||
|
||||
function GuestForm({ guest, onClose }) {
|
||||
const [formData, setFormData] = useState({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone_number: '',
|
||||
rsvp_status: 'pending',
|
||||
meal_preference: '',
|
||||
has_plus_one: false,
|
||||
plus_one_name: '',
|
||||
notes: '',
|
||||
table_number: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (guest) {
|
||||
setFormData(guest)
|
||||
}
|
||||
}, [guest])
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
if (guest) {
|
||||
await updateGuest(guest.id, formData)
|
||||
} else {
|
||||
await createGuest(formData)
|
||||
}
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Error saving guest:', error)
|
||||
alert('Failed to save guest')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{guest ? 'Edit Guest' : 'Add New Guest'}</h2>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone_number"
|
||||
value={formData.phone_number}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>RSVP Status</label>
|
||||
<select
|
||||
name="rsvp_status"
|
||||
value={formData.rsvp_status}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="accepted">Accepted</option>
|
||||
<option value="declined">Declined</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Meal Preference</label>
|
||||
<select
|
||||
name="meal_preference"
|
||||
value={formData.meal_preference}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">No preference</option>
|
||||
<option value="vegetarian">Vegetarian</option>
|
||||
<option value="vegan">Vegan</option>
|
||||
<option value="gluten-free">Gluten-free</option>
|
||||
<option value="kosher">Kosher</option>
|
||||
<option value="halal">Halal</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="has_plus_one"
|
||||
checked={formData.has_plus_one}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
Has Plus One
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.has_plus_one && (
|
||||
<div className="form-group">
|
||||
<label>Plus One Name</label>
|
||||
<input
|
||||
type="text"
|
||||
name="plus_one_name"
|
||||
value={formData.plus_one_name}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Table Number</label>
|
||||
<input
|
||||
type="number"
|
||||
name="table_number"
|
||||
value={formData.table_number}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Notes</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
value={formData.notes}
|
||||
onChange={handleChange}
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{guest ? 'Update' : 'Add'} Guest
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GuestForm
|
||||
205
frontend/src/components/GuestList.css
Normal file
205
frontend/src/components/GuestList.css
Normal file
@ -0,0 +1,205 @@
|
||||
.guest-list {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.list-header h2 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.list-controls {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-controls label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.list-controls select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.guest-list h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: #f9fafb;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #bfdbfe;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
.no-guests {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.no-guests p {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.owner-cell {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
199
frontend/src/components/GuestList.jsx
Normal file
199
frontend/src/components/GuestList.jsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { useState } from 'react'
|
||||
import { deleteGuest, deleteGuestsBulk } from '../api/api'
|
||||
import './GuestList.css'
|
||||
|
||||
function GuestList({ guests, onEdit, onUpdate }) {
|
||||
const [selectedGuests, setSelectedGuests] = useState([])
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(100)
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = pageSize === 'all' ? 1 : Math.ceil(guests.length / pageSize)
|
||||
const startIndex = pageSize === 'all' ? 0 : (currentPage - 1) * pageSize
|
||||
const endIndex = pageSize === 'all' ? guests.length : startIndex + pageSize
|
||||
const paginatedGuests = guests.slice(startIndex, endIndex)
|
||||
|
||||
const handleSelectAll = (e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedGuests(paginatedGuests.map(g => g.id))
|
||||
} else {
|
||||
setSelectedGuests([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectOne = (guestId) => {
|
||||
if (selectedGuests.includes(guestId)) {
|
||||
setSelectedGuests(selectedGuests.filter(id => id !== guestId))
|
||||
} else {
|
||||
setSelectedGuests([...selectedGuests, guestId])
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedGuests.length === 0) return
|
||||
|
||||
if (window.confirm(`Are you sure you want to delete ${selectedGuests.length} guests?`)) {
|
||||
try {
|
||||
await deleteGuestsBulk(selectedGuests)
|
||||
setSelectedGuests([])
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
console.error('Error deleting guests:', error)
|
||||
alert('Failed to delete guests')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (window.confirm('Are you sure you want to delete this guest?')) {
|
||||
try {
|
||||
await deleteGuest(id)
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
console.error('Error deleting guest:', error)
|
||||
alert('Failed to delete guest')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getRsvpBadgeClass = (status) => {
|
||||
switch (status) {
|
||||
case 'accepted':
|
||||
return 'badge-success'
|
||||
case 'declined':
|
||||
return 'badge-danger'
|
||||
default:
|
||||
return 'badge-warning'
|
||||
}
|
||||
}
|
||||
|
||||
if (guests.length === 0) {
|
||||
return (
|
||||
<div className="no-guests">
|
||||
<p>No guests found. Add your first guest to get started!</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="guest-list">
|
||||
<div className="list-header">
|
||||
<h2>Guest List ({guests.length})</h2>
|
||||
<div className="list-controls">
|
||||
<label>
|
||||
Show:
|
||||
<select value={pageSize} onChange={(e) => {
|
||||
const value = e.target.value === 'all' ? 'all' : parseInt(e.target.value)
|
||||
setPageSize(value)
|
||||
setCurrentPage(1)
|
||||
}}>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</label>
|
||||
{selectedGuests.length > 0 && (
|
||||
<button className="btn btn-danger" onClick={handleBulkDelete}>
|
||||
Delete Selected ({selectedGuests.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={handleSelectAll}
|
||||
checked={paginatedGuests.length > 0 && selectedGuests.length === paginatedGuests.length}
|
||||
/>
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>RSVP</th>
|
||||
<th>Meal</th>
|
||||
<th>Plus One</th>
|
||||
<th>Table</th>
|
||||
<th>Owner</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedGuests.map((guest) => (
|
||||
<tr key={guest.id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedGuests.includes(guest.id)}
|
||||
onChange={() => handleSelectOne(guest.id)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{guest.first_name} {guest.last_name}</strong>
|
||||
</td>
|
||||
<td>{guest.email || '-'}</td>
|
||||
<td>{guest.phone_number || '-'}</td>
|
||||
<td>
|
||||
<span className={`badge ${getRsvpBadgeClass(guest.rsvp_status)}`}>
|
||||
{guest.rsvp_status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{guest.meal_preference || '-'}</td>
|
||||
<td>
|
||||
{guest.has_plus_one ? (
|
||||
<span>✓ {guest.plus_one_name || 'Yes'}</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td>{guest.table_number || '-'}</td>
|
||||
<td className="owner-cell">{guest.owner || '-'}</td>
|
||||
<td className="actions">
|
||||
<button
|
||||
className="btn-small btn-edit"
|
||||
onClick={() => onEdit(guest)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn-small btn-delete"
|
||||
onClick={() => handleDelete(guest.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pageSize !== 'all' && totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GuestList
|
||||
75
frontend/src/components/SearchFilter.css
Normal file
75
frontend/src/components/SearchFilter.css
Normal file
@ -0,0 +1,75 @@
|
||||
.search-filter {
|
||||
background: #f9fafb;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.search-filter select {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-filter select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
padding: 12px 20px;
|
||||
background: #374151;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-reset:hover {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
132
frontend/src/components/SearchFilter.jsx
Normal file
132
frontend/src/components/SearchFilter.jsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getOwners, undoImport } from '../api/api'
|
||||
import './SearchFilter.css'
|
||||
|
||||
function SearchFilter({ onSearch }) {
|
||||
const [filters, setFilters] = useState({
|
||||
query: '',
|
||||
rsvpStatus: '',
|
||||
mealPreference: '',
|
||||
owner: ''
|
||||
})
|
||||
const [owners, setOwners] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
loadOwners()
|
||||
}, [])
|
||||
|
||||
const loadOwners = async () => {
|
||||
try {
|
||||
const data = await getOwners()
|
||||
setOwners(data.owners || [])
|
||||
} catch (error) {
|
||||
console.error('Error loading owners:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[name]: value
|
||||
}
|
||||
setFilters(newFilters)
|
||||
onSearch(newFilters)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
const resetFilters = {
|
||||
query: '',
|
||||
rsvpStatus: '',
|
||||
mealPreference: '',
|
||||
owner: ''
|
||||
}
|
||||
setFilters(resetFilters)
|
||||
onSearch(resetFilters)
|
||||
}
|
||||
|
||||
const handleUndoImport = async () => {
|
||||
if (!filters.owner) {
|
||||
alert('Please select an owner to undo their import')
|
||||
return
|
||||
}
|
||||
|
||||
if (window.confirm(`Are you sure you want to delete all guests imported by ${filters.owner}?`)) {
|
||||
try {
|
||||
const result = await undoImport(filters.owner)
|
||||
alert(result.message)
|
||||
handleReset()
|
||||
loadOwners()
|
||||
onSearch({})
|
||||
} catch (error) {
|
||||
console.error('Error undoing import:', error)
|
||||
alert('Failed to undo import')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="search-filter">
|
||||
<div className="filter-row">
|
||||
<div className="search-box">
|
||||
<input
|
||||
type="text"
|
||||
name="query"
|
||||
value={filters.query}
|
||||
onChange={handleChange}
|
||||
placeholder="Search by name, email, or phone..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
name="rsvpStatus"
|
||||
value={filters.rsvpStatus}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">All RSVP Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="accepted">Accepted</option>
|
||||
<option value="declined">Declined</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
name="mealPreference"
|
||||
value={filters.mealPreference}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">All Meals</option>
|
||||
<option value="vegetarian">Vegetarian</option>
|
||||
<option value="vegan">Vegan</option>
|
||||
<option value="gluten-free">Gluten-free</option>
|
||||
<option value="kosher">Kosher</option>
|
||||
<option value="halal">Halal</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
name="owner"
|
||||
value={filters.owner}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">All Guests</option>
|
||||
{owners.map(owner => (
|
||||
<option key={owner} value={owner}>{owner}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{filters.owner && (
|
||||
<button className="btn btn-secondary" onClick={handleUndoImport}>
|
||||
Undo Import
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(filters.query || filters.rsvpStatus || filters.mealPreference || filters.owner) && (
|
||||
<button className="btn-reset" onClick={handleReset}>
|
||||
Clear Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchFilter
|
||||
19
frontend/src/index.css
Normal file
19
frontend/src/index.css
Normal file
@ -0,0 +1,19 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
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;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
10
frontend/vite.config.js
Normal file
10
frontend/vite.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
25
start.bat
Normal file
25
start.bat
Normal file
@ -0,0 +1,25 @@
|
||||
@echo off
|
||||
echo Starting Wedding Guest List Application...
|
||||
echo.
|
||||
|
||||
echo [1/3] Starting PostgreSQL (make sure it's running)
|
||||
timeout /t 2 /nobreak > nul
|
||||
|
||||
echo [2/3] Starting Backend Server...
|
||||
start "Backend API" cmd /k "cd /d %~dp0backend && uvicorn main:app --reload"
|
||||
timeout /t 3 /nobreak > nul
|
||||
|
||||
echo [3/3] Starting Frontend...
|
||||
start "Frontend Dev Server" cmd /k "cd /d %~dp0frontend && npm run dev"
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Application is starting!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo Backend API: http://localhost:8000
|
||||
echo API Docs: http://localhost:8000/docs
|
||||
echo Frontend: http://localhost:5173
|
||||
echo.
|
||||
echo Press any key to close this window...
|
||||
pause > nul
|
||||
35
start.sh
Normal file
35
start.sh
Normal file
@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Starting Wedding Guest List Application..."
|
||||
echo
|
||||
|
||||
echo "[1/3] Starting PostgreSQL (make sure it's running)"
|
||||
sleep 2
|
||||
|
||||
echo "[2/3] Starting Backend Server..."
|
||||
cd backend
|
||||
uvicorn main:app --reload &
|
||||
BACKEND_PID=$!
|
||||
cd ..
|
||||
sleep 3
|
||||
|
||||
echo "[3/3] Starting Frontend..."
|
||||
cd frontend
|
||||
npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
cd ..
|
||||
|
||||
echo
|
||||
echo "========================================"
|
||||
echo "Application is running!"
|
||||
echo "========================================"
|
||||
echo
|
||||
echo "Backend API: http://localhost:8000"
|
||||
echo "API Docs: http://localhost:8000/docs"
|
||||
echo "Frontend: http://localhost:5173"
|
||||
echo
|
||||
echo "Press Ctrl+C to stop all services"
|
||||
|
||||
# Wait for Ctrl+C
|
||||
trap "kill $BACKEND_PID $FRONTEND_PID; exit" INT
|
||||
wait
|
||||
Loading…
x
Reference in New Issue
Block a user