first prototype commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import os
|
||||
import jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
SECRET_KEY = os.getenv("JWT_SECRET", "choreus-super-secret-key-2026-family-chores")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_DAYS = 30
|
||||
|
||||
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
def get_current_week_identifier() -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
year, week, _ = now.isocalendar()
|
||||
return f"{year}-W{week:02d}"
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "choreus.db")
|
||||
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_PATH}"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from backend.database import engine, Base, SessionLocal
|
||||
from backend.models import User, ChoreType, ChoreCompletion, UserRole, RecurrenceType
|
||||
from backend.routers import auth, users, chores, reports
|
||||
from backend.config import get_current_week_identifier
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
def seed_initial_data():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Seed Users if empty
|
||||
if db.query(User).count() == 0:
|
||||
admin_user = User(
|
||||
email="admin@choreus.app",
|
||||
name="Sarah (Admin)",
|
||||
avatar_url="https://api.dicebear.com/7.x/bottts/svg?seed=SarahAdmin",
|
||||
role=UserRole.ADMIN.value,
|
||||
weekly_star_quota=20
|
||||
)
|
||||
kid_user1 = User(
|
||||
email="leo@choreus.app",
|
||||
name="Leo",
|
||||
avatar_url="https://api.dicebear.com/7.x/bottts/svg?seed=LeoKid",
|
||||
role=UserRole.REGULAR.value,
|
||||
weekly_star_quota=15
|
||||
)
|
||||
kid_user2 = User(
|
||||
email="maya@choreus.app",
|
||||
name="Maya",
|
||||
avatar_url="https://api.dicebear.com/7.x/bottts/svg?seed=MayaKid",
|
||||
role=UserRole.REGULAR.value,
|
||||
weekly_star_quota=15
|
||||
)
|
||||
db.add_all([admin_user, kid_user1, kid_user2])
|
||||
db.commit()
|
||||
|
||||
# Seed Chores if empty
|
||||
if db.query(ChoreType).count() == 0:
|
||||
chores_list = [
|
||||
ChoreType(title="Empty & fill dishwasher", description="Clear dishes, load dirty ones and start cycle", star_reward=1, recurrence=RecurrenceType.DAILY.value, icon="Utensils"),
|
||||
ChoreType(title="Take out trash & recycling", description="Empty kitchen bin and carry bins to curb", star_reward=1, recurrence=RecurrenceType.DAILY.value, icon="Trash2"),
|
||||
ChoreType(title="Vacuum living room", description="Vacuum carpet and couch cushions thoroughly", star_reward=3, recurrence=RecurrenceType.WEEKLY.value, icon="Broom"),
|
||||
ChoreType(title="Clean bathroom", description="Scrub sink, mirror, toilet, and wipe floor", star_reward=4, recurrence=RecurrenceType.WEEKLY.value, icon="Sparkles"),
|
||||
ChoreType(title="Clear snow from driveway", description="Shovel front driveway and salt walkways after snow fall", star_reward=5, recurrence=RecurrenceType.SPONTANEOUS.value, icon="Snowflake"),
|
||||
ChoreType(title="Go buy groceries", description="Pick up weekly groceries from list", star_reward=4, recurrence=RecurrenceType.SPONTANEOUS.value, icon="ShoppingBag"),
|
||||
]
|
||||
db.add_all(chores_list)
|
||||
db.commit()
|
||||
|
||||
# Seed sample completions if none exist
|
||||
if db.query(ChoreCompletion).count() == 0:
|
||||
users_list = db.query(User).all()
|
||||
chores_types = db.query(ChoreType).all()
|
||||
current_week = get_current_week_identifier()
|
||||
|
||||
if len(users_list) >= 3 and len(chores_types) >= 3:
|
||||
c1 = ChoreCompletion(
|
||||
chore_type_id=chores_types[0].id,
|
||||
user_id=users_list[1].id, # Leo
|
||||
stars_earned=chores_types[0].star_reward,
|
||||
week_identifier=current_week,
|
||||
completed_at=datetime.now(timezone.utc) - timedelta(hours=5),
|
||||
notes="All clean!"
|
||||
)
|
||||
c2 = ChoreCompletion(
|
||||
chore_type_id=chores_types[2].id,
|
||||
user_id=users_list[2].id, # Maya
|
||||
stars_earned=chores_types[2].star_reward,
|
||||
week_identifier=current_week,
|
||||
completed_at=datetime.now(timezone.utc) - timedelta(hours=2),
|
||||
notes="Living room carpet looks great"
|
||||
)
|
||||
db.add_all([c1, c2])
|
||||
db.commit()
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
seed_initial_data()
|
||||
yield
|
||||
|
||||
app = FastAPI(title="ChoreUS API", version="1.0.0", description="Home Chore Manager API", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(chores.router)
|
||||
app.include_router(reports.router)
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "Welcome to ChoreUS Home Chore Manager API", "status": "running"}
|
||||
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
from backend.database import Base
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
ADMIN = "admin"
|
||||
REGULAR = "regular"
|
||||
|
||||
class RecurrenceType(str, enum.Enum):
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
SPONTANEOUS = "spontaneous"
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
google_id = Column(String, unique=True, nullable=True, index=True)
|
||||
avatar_url = Column(String, nullable=True)
|
||||
role = Column(String, default=UserRole.REGULAR.value, nullable=False)
|
||||
weekly_star_quota = Column(Integer, default=15, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
completions = relationship("ChoreCompletion", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
class ChoreType(Base):
|
||||
__tablename__ = "chore_types"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
star_reward = Column(Integer, default=1, nullable=False) # 1 to 5 stars
|
||||
recurrence = Column(String, default=RecurrenceType.DAILY.value, nullable=False) # daily, weekly, spontaneous
|
||||
icon = Column(String, default="CheckSquare", nullable=False)
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
completions = relationship("ChoreCompletion", back_populates="chore_type", cascade="all, delete-orphan")
|
||||
|
||||
class ChoreCompletion(Base):
|
||||
__tablename__ = "chore_completions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
chore_type_id = Column(Integer, ForeignKey("chore_types.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
completed_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
stars_earned = Column(Integer, nullable=False)
|
||||
week_identifier = Column(String, nullable=False, index=True) # e.g., '2026-W32'
|
||||
notes = Column(String, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="completions")
|
||||
chore_type = relationship("ChoreType", back_populates="completions")
|
||||
@@ -0,0 +1,113 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.database import get_db
|
||||
from backend.models import User, UserRole
|
||||
from backend.schemas import UserOut, GoogleAuthRequest, DemoLoginRequest
|
||||
from backend.config import create_access_token, decode_access_token, GOOGLE_CLIENT_ID
|
||||
from typing import Optional
|
||||
from google.oauth2 import id_token
|
||||
from google.auth.transport import requests as google_requests
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
def get_current_user(authorization: Optional[str] = Header(None), db: Session = Depends(get_db)) -> User:
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing authorization header")
|
||||
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authorization header format")
|
||||
|
||||
token = parts[1]
|
||||
payload = decode_access_token(token)
|
||||
if not payload or "sub" not in payload:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
|
||||
|
||||
user_id = int(payload["sub"])
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
|
||||
|
||||
return user
|
||||
|
||||
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.role != UserRole.ADMIN.value:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
|
||||
return current_user
|
||||
|
||||
@router.post("/google")
|
||||
def google_auth(req: GoogleAuthRequest, db: Session = Depends(get_db)):
|
||||
email = req.email
|
||||
name = req.name or "Google User"
|
||||
avatar_url = req.avatar_url or f"https://api.dicebear.com/7.x/bottts/svg?seed={email}"
|
||||
google_sub = None
|
||||
|
||||
# Try verifying real Google token if client ID configured and credential provided
|
||||
if req.credential and len(req.credential) > 50:
|
||||
try:
|
||||
if GOOGLE_CLIENT_ID:
|
||||
id_info = id_token.verify_oauth2_token(req.credential, google_requests.Request(), GOOGLE_CLIENT_ID)
|
||||
else:
|
||||
id_info = id_token.verify_oauth2_token(req.credential, google_requests.Request())
|
||||
email = id_info.get("email", email)
|
||||
name = id_info.get("name", name)
|
||||
avatar_url = id_info.get("picture", avatar_url)
|
||||
google_sub = id_info.get("sub")
|
||||
except Exception as e:
|
||||
# Fallback to provided details if token decode fails in dev
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail=f"Google token verification failed: {str(e)}")
|
||||
|
||||
if not email:
|
||||
raise HTTPException(status_code=400, detail="Email is required")
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
|
||||
# Check if this is the first user ever registered
|
||||
total_users = db.query(User).count()
|
||||
initial_role = UserRole.ADMIN.value if total_users == 0 else UserRole.REGULAR.value
|
||||
|
||||
if not user:
|
||||
user = User(
|
||||
email=email,
|
||||
name=name,
|
||||
avatar_url=avatar_url,
|
||||
google_id=google_sub,
|
||||
role=initial_role,
|
||||
weekly_star_quota=15
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
else:
|
||||
# Update existing user google_id or default avatar if missing
|
||||
if google_sub and not user.google_id:
|
||||
user.google_id = google_sub
|
||||
if avatar_url and not user.avatar_url:
|
||||
user.avatar_url = avatar_url
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
token = create_access_token({"sub": str(user.id), "email": user.email, "role": user.role})
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": UserOut.model_validate(user)
|
||||
}
|
||||
|
||||
@router.post("/demo-login")
|
||||
def demo_login(req: DemoLoginRequest, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.email == req.email).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Demo user not found. Run backend initialization first.")
|
||||
|
||||
token = create_access_token({"sub": str(user.id), "email": user.email, "role": user.role})
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": UserOut.model_validate(user)
|
||||
}
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def get_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
@@ -0,0 +1,132 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from backend.database import get_db
|
||||
from backend.models import User, ChoreType, ChoreCompletion, UserRole, RecurrenceType
|
||||
from backend.schemas import ChoreTypeOut, ChoreTypeCreate, ChoreTypeUpdate, ChoreCompletionCreate, ChoreCompletionOut
|
||||
from backend.routers.auth import get_current_user, get_admin_user
|
||||
from backend.config import get_current_week_identifier
|
||||
|
||||
router = APIRouter(prefix="/api/chores", tags=["chores"])
|
||||
|
||||
@router.get("/types", response_model=List[ChoreTypeOut])
|
||||
def list_chore_types(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
return db.query(ChoreType).order_by(ChoreType.id.asc()).all()
|
||||
|
||||
@router.post("/types", response_model=ChoreTypeOut)
|
||||
def create_chore_type(chore_in: ChoreTypeCreate, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||
if chore_in.star_reward < 1 or chore_in.star_reward > 5:
|
||||
raise HTTPException(status_code=400, detail="Star reward must be between 1 and 5")
|
||||
|
||||
if chore_in.recurrence not in [r.value for r in RecurrenceType]:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid recurrence type. Must be daily, weekly, or spontaneous")
|
||||
|
||||
new_chore = ChoreType(
|
||||
title=chore_in.title,
|
||||
description=chore_in.description,
|
||||
star_reward=chore_in.star_reward,
|
||||
recurrence=chore_in.recurrence,
|
||||
icon=chore_in.icon
|
||||
)
|
||||
db.add(new_chore)
|
||||
db.commit()
|
||||
db.refresh(new_chore)
|
||||
return new_chore
|
||||
|
||||
@router.patch("/types/{chore_id}", response_model=ChoreTypeOut)
|
||||
def update_chore_type(chore_id: int, chore_in: ChoreTypeUpdate, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||
chore = db.query(ChoreType).filter(ChoreType.id == chore_id).first()
|
||||
if not chore:
|
||||
raise HTTPException(status_code=404, detail="Chore type not found")
|
||||
|
||||
if chore_in.title is not None:
|
||||
chore.title = chore_in.title
|
||||
if chore_in.description is not None:
|
||||
chore.description = chore_in.description
|
||||
if chore_in.star_reward is not None:
|
||||
if chore_in.star_reward < 1 or chore_in.star_reward > 5:
|
||||
raise HTTPException(status_code=400, detail="Star reward must be between 1 and 5")
|
||||
chore.star_reward = chore_in.star_reward
|
||||
if chore_in.recurrence is not None:
|
||||
if chore_in.recurrence not in [r.value for r in RecurrenceType]:
|
||||
raise HTTPException(status_code=400, detail="Invalid recurrence type")
|
||||
chore.recurrence = chore_in.recurrence
|
||||
if chore_in.icon is not None:
|
||||
chore.icon = chore_in.icon
|
||||
|
||||
db.commit()
|
||||
db.refresh(chore)
|
||||
return chore
|
||||
|
||||
@router.delete("/types/{chore_id}")
|
||||
def delete_chore_type(chore_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||
chore = db.query(ChoreType).filter(ChoreType.id == chore_id).first()
|
||||
if not chore:
|
||||
raise HTTPException(status_code=404, detail="Chore type not found")
|
||||
|
||||
db.delete(chore)
|
||||
db.commit()
|
||||
return {"message": "Chore type deleted successfully", "id": chore_id}
|
||||
|
||||
@router.post("/complete", response_model=ChoreCompletionOut)
|
||||
def complete_chore(payload: ChoreCompletionCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
chore_type = db.query(ChoreType).filter(ChoreType.id == payload.chore_type_id).first()
|
||||
if not chore_type:
|
||||
raise HTTPException(status_code=404, detail="Chore type not found")
|
||||
|
||||
week_id = get_current_week_identifier()
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
|
||||
completion = ChoreCompletion(
|
||||
chore_type_id=chore_type.id,
|
||||
user_id=current_user.id,
|
||||
completed_at=now_utc,
|
||||
stars_earned=chore_type.star_reward,
|
||||
week_identifier=week_id,
|
||||
notes=payload.notes
|
||||
)
|
||||
db.add(completion)
|
||||
db.commit()
|
||||
db.refresh(completion)
|
||||
|
||||
return ChoreCompletionOut(
|
||||
id=completion.id,
|
||||
chore_type_id=completion.chore_type_id,
|
||||
user_id=completion.user_id,
|
||||
completed_at=completion.completed_at,
|
||||
stars_earned=completion.stars_earned,
|
||||
week_identifier=completion.week_identifier,
|
||||
notes=completion.notes,
|
||||
user_name=current_user.name,
|
||||
user_avatar=current_user.avatar_url,
|
||||
chore_title=chore_type.title,
|
||||
chore_icon=chore_type.icon
|
||||
)
|
||||
|
||||
@router.get("/completions", response_model=List[ChoreCompletionOut])
|
||||
def get_recent_completions(week: Optional[str] = None, user_id: Optional[int] = None, limit: int = 50, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
query = db.query(ChoreCompletion)
|
||||
if week:
|
||||
query = query.filter(ChoreCompletion.week_identifier == week)
|
||||
if user_id:
|
||||
query = query.filter(ChoreCompletion.user_id == user_id)
|
||||
|
||||
completions = query.order_by(ChoreCompletion.completed_at.desc()).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for c in completions:
|
||||
result.append(ChoreCompletionOut(
|
||||
id=c.id,
|
||||
chore_type_id=c.chore_type_id,
|
||||
user_id=c.user_id,
|
||||
completed_at=c.completed_at,
|
||||
stars_earned=c.stars_earned,
|
||||
week_identifier=c.week_identifier,
|
||||
notes=c.notes,
|
||||
user_name=c.user.name if c.user else "Unknown",
|
||||
user_avatar=c.user.avatar_url if c.user else None,
|
||||
chore_title=c.chore_type.title if c.chore_type else "Chore",
|
||||
chore_icon=c.chore_type.icon if c.chore_type else "CheckSquare"
|
||||
))
|
||||
return result
|
||||
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from backend.database import get_db
|
||||
from backend.models import User, ChoreCompletion, ChoreType
|
||||
from backend.schemas import WeeklyReportOut, UserWeekProgress
|
||||
from backend.routers.auth import get_current_user
|
||||
from backend.config import get_current_week_identifier
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
@router.get("/weeks")
|
||||
def get_available_weeks(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
# Returns all distinct week_identifiers present in database plus current week
|
||||
weeks = db.query(ChoreCompletion.week_identifier).distinct().all()
|
||||
week_set = {w[0] for w in weeks if w[0]}
|
||||
current_week = get_current_week_identifier()
|
||||
week_set.add(current_week)
|
||||
sorted_weeks = sorted(list(week_set), reverse=True)
|
||||
return {"current_week": current_week, "weeks": sorted_weeks}
|
||||
|
||||
@router.get("/weekly", response_model=WeeklyReportOut)
|
||||
def get_weekly_report(week: Optional[str] = Query(None), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
target_week = week if week else get_current_week_identifier()
|
||||
users = db.query(User).filter(User.is_active == True).order_by(User.id.asc()).all()
|
||||
|
||||
user_progress_list: List[UserWeekProgress] = []
|
||||
total_stars_earned = 0
|
||||
total_completions = 0
|
||||
|
||||
for user in users:
|
||||
completions = db.query(ChoreCompletion).filter(
|
||||
ChoreCompletion.user_id == user.id,
|
||||
ChoreCompletion.week_identifier == target_week
|
||||
).all()
|
||||
|
||||
stars = sum(c.stars_earned for c in completions)
|
||||
comp_count = len(completions)
|
||||
|
||||
total_stars_earned += stars
|
||||
total_completions += comp_count
|
||||
|
||||
quota = user.weekly_star_quota if user.weekly_star_quota > 0 else 1
|
||||
pct = round(min(100.0, (stars / quota) * 100), 1)
|
||||
|
||||
user_progress_list.append(UserWeekProgress(
|
||||
user_id=user.id,
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
avatar_url=user.avatar_url,
|
||||
role=user.role,
|
||||
weekly_star_quota=user.weekly_star_quota,
|
||||
stars_earned=stars,
|
||||
percentage=pct,
|
||||
completions_count=comp_count
|
||||
))
|
||||
|
||||
# Sort users by stars earned descending
|
||||
user_progress_list.sort(key=lambda u: u.stars_earned, reverse=True)
|
||||
|
||||
return WeeklyReportOut(
|
||||
week_identifier=target_week,
|
||||
user_progress=user_progress_list,
|
||||
total_stars_earned=total_stars_earned,
|
||||
total_completions=total_completions
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from backend.database import get_db
|
||||
from backend.models import User, UserRole
|
||||
from backend.schemas import UserOut, UserCreate, UserUpdate
|
||||
from backend.routers.auth import get_current_user, get_admin_user
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
@router.get("", response_model=List[UserOut])
|
||||
def get_all_users(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
return db.query(User).order_by(User.id.asc()).all()
|
||||
|
||||
@router.post("", response_model=UserOut)
|
||||
def create_user(user_in: UserCreate, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||
existing = db.query(User).filter(User.email == user_in.email).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="User with this email already exists")
|
||||
|
||||
avatar = user_in.avatar_url or f"https://api.dicebear.com/7.x/bottts/svg?seed={user_in.email}"
|
||||
new_user = User(
|
||||
email=user_in.email,
|
||||
name=user_in.name,
|
||||
google_id=user_in.google_id,
|
||||
avatar_url=avatar,
|
||||
role=user_in.role,
|
||||
weekly_star_quota=user_in.weekly_star_quota
|
||||
)
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
|
||||
@router.get("/{user_id}", response_model=UserOut)
|
||||
def get_user_by_id(user_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserOut)
|
||||
def update_user(user_id: int, user_in: UserUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
# Regular users can only update their own avatar and name. Admins can update role, quota, active status.
|
||||
if current_user.role != UserRole.ADMIN.value and current_user.id != user_id:
|
||||
raise HTTPException(status_code=403, detail="Cannot edit another user's account")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if user_in.name is not None:
|
||||
user.name = user_in.name
|
||||
if user_in.avatar_url is not None:
|
||||
user.avatar_url = user_in.avatar_url
|
||||
|
||||
# Admin-only fields
|
||||
if current_user.role == UserRole.ADMIN.value:
|
||||
if user_in.role is not None:
|
||||
user.role = user_in.role
|
||||
if user_in.weekly_star_quota is not None:
|
||||
user.weekly_star_quota = user_in.weekly_star_quota
|
||||
if user_in.is_active is not None:
|
||||
user.is_active = user_in.is_active
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
|
||||
if admin.id == user_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own admin account")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"message": "User deleted successfully", "id": user_id}
|
||||
@@ -0,0 +1,96 @@
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
name: str
|
||||
avatar_url: Optional[str] = None
|
||||
role: str = "regular"
|
||||
weekly_star_quota: int = Field(default=15, ge=1, le=100)
|
||||
|
||||
class UserCreate(UserBase):
|
||||
google_id: Optional[str] = None
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
weekly_star_quota: Optional[int] = Field(default=None, ge=1, le=100)
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class UserOut(UserBase):
|
||||
id: int
|
||||
google_id: Optional[str] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class ChoreTypeBase(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
star_reward: int = Field(default=1, ge=1, le=5)
|
||||
recurrence: str = Field(default="daily", description="daily, weekly, spontaneous")
|
||||
icon: str = "CheckSquare"
|
||||
|
||||
class ChoreTypeCreate(ChoreTypeBase):
|
||||
pass
|
||||
|
||||
class ChoreTypeUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
star_reward: Optional[int] = Field(default=None, ge=1, le=5)
|
||||
recurrence: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
|
||||
class ChoreTypeOut(ChoreTypeBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class ChoreCompletionCreate(BaseModel):
|
||||
chore_type_id: int
|
||||
notes: Optional[str] = None
|
||||
|
||||
class ChoreCompletionOut(BaseModel):
|
||||
id: int
|
||||
chore_type_id: int
|
||||
user_id: int
|
||||
completed_at: datetime
|
||||
stars_earned: int
|
||||
week_identifier: str
|
||||
notes: Optional[str] = None
|
||||
user_name: Optional[str] = None
|
||||
user_avatar: Optional[str] = None
|
||||
chore_title: Optional[str] = None
|
||||
chore_icon: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class UserWeekProgress(BaseModel):
|
||||
user_id: int
|
||||
name: str
|
||||
email: str
|
||||
avatar_url: Optional[str]
|
||||
role: str
|
||||
weekly_star_quota: int
|
||||
stars_earned: int
|
||||
percentage: float
|
||||
completions_count: int
|
||||
|
||||
class WeeklyReportOut(BaseModel):
|
||||
week_identifier: str
|
||||
user_progress: List[UserWeekProgress]
|
||||
total_stars_earned: int
|
||||
total_completions: int
|
||||
|
||||
class GoogleAuthRequest(BaseModel):
|
||||
credential: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
name: Optional[str] = None
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
class DemoLoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
@@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
|
||||
def test_root_endpoint():
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "running"
|
||||
|
||||
def test_demo_login():
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/auth/demo-login", json={"email": "admin@choreus.app"})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["user"]["email"] == "admin@choreus.app"
|
||||
assert data["user"]["role"] == "admin"
|
||||
|
||||
def test_google_auth_mock():
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/api/auth/google", json={
|
||||
"credential": "mock_token",
|
||||
"email": "newuser@choreus.app",
|
||||
"name": "New User",
|
||||
"avatar_url": "https://api.dicebear.com/7.x/bottts/svg?seed=NewUser"
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user"]["email"] == "newuser@choreus.app"
|
||||
assert data["user"]["role"] == "regular"
|
||||
@@ -0,0 +1,47 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
|
||||
def test_list_chores():
|
||||
with TestClient(app) as client:
|
||||
auth_res = client.post("/api/auth/demo-login", json={"email": "admin@choreus.app"})
|
||||
token = auth_res.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = client.get("/api/chores/types", headers=headers)
|
||||
assert response.status_code == 200
|
||||
chores = response.json()
|
||||
assert len(chores) >= 1
|
||||
assert "title" in chores[0]
|
||||
|
||||
def test_create_and_complete_chore():
|
||||
with TestClient(app) as client:
|
||||
admin_res = client.post("/api/auth/demo-login", json={"email": "admin@choreus.app"})
|
||||
admin_token = admin_res.json()["access_token"]
|
||||
admin_headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
# Create chore
|
||||
chore_res = client.post("/api/chores/types", headers=admin_headers, json={
|
||||
"title": "Clean Garage",
|
||||
"description": "Organize tools and sweep floor",
|
||||
"star_reward": 5,
|
||||
"recurrence": "weekly",
|
||||
"icon": "Wrench"
|
||||
})
|
||||
assert chore_res.status_code == 200
|
||||
chore_data = chore_res.json()
|
||||
chore_id = chore_data["id"]
|
||||
|
||||
# Complete chore as user
|
||||
user_res = client.post("/api/auth/demo-login", json={"email": "leo@choreus.app"})
|
||||
user_token = user_res.json()["access_token"]
|
||||
user_headers = {"Authorization": f"Bearer {user_token}"}
|
||||
|
||||
comp_res = client.post("/api/chores/complete", headers=user_headers, json={
|
||||
"chore_type_id": chore_id,
|
||||
"notes": "Done with garage!"
|
||||
})
|
||||
assert comp_res.status_code == 200
|
||||
comp_data = comp_res.json()
|
||||
assert comp_data["stars_earned"] == 5
|
||||
assert comp_data["chore_title"] == "Clean Garage"
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.30.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"pinia": "^4.0.2",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"vite": "^8.2.0"
|
||||
}
|
||||
}
|
||||
Generated
+1379
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 text-slate-100 flex flex-col md:flex-row">
|
||||
<!-- Navigation Sidebar / Mobile Bar -->
|
||||
<Navbar v-if="authStore.isAuthenticated" />
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main
|
||||
class="flex-1 p-4 md:p-8 mb-16 md:mb-0 transition-all"
|
||||
:class="authStore.isAuthenticated ? 'md:ml-64' : ''"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import Navbar from './components/Navbar.vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
onMounted(async () => {
|
||||
if (authStore.token) {
|
||||
await authStore.fetchCurrentUser();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<component :is="iconComponent" :class="customClass || 'w-5 h-5'" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import * as icons from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: 'CheckSquare'
|
||||
},
|
||||
customClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (props.name && icons[props.name]) {
|
||||
return icons[props.name];
|
||||
}
|
||||
// Common fallback maps
|
||||
const fallbackMap = {
|
||||
'Utensils': icons.Utensils || icons.CheckSquare,
|
||||
'Broom': icons.Sparkles || icons.CheckSquare,
|
||||
'Snowflake': icons.Snowflake || icons.Sparkles,
|
||||
'ShoppingBag': icons.ShoppingBag || icons.ShoppingCart || icons.CheckSquare,
|
||||
'Trash2': icons.Trash2 || icons.Trash,
|
||||
'Sparkles': icons.Sparkles,
|
||||
'Wrench': icons.Wrench,
|
||||
'Shirt': icons.Shirt
|
||||
};
|
||||
return fallbackMap[props.name] || icons.CheckSquare;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Desktop Sidebar -->
|
||||
<aside class="hidden md:flex flex-col w-64 glass-panel border-r border-slate-700/50 min-h-screen fixed left-0 top-0 z-30 p-4">
|
||||
<!-- App Header & Logo -->
|
||||
<div class="flex items-center space-x-3 px-2 py-4 mb-6">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-amber-500 to-emerald-400 flex items-center justify-center shadow-lg shadow-amber-500/20">
|
||||
<Sparkles class="w-6 h-6 text-slate-950 font-bold" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold tracking-tight bg-gradient-to-r from-amber-400 via-amber-200 to-emerald-400 bg-clip-text text-transparent">ChoreUS</h1>
|
||||
<p class="text-xs text-slate-400 font-medium">Home Chore Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Mini Profile -->
|
||||
<div v-if="authStore.user" class="glass-card rounded-xl p-3 mb-6 flex items-center space-x-3 border border-slate-700/50">
|
||||
<img :src="authStore.user.avatar_url || defaultAvatar" class="w-10 h-10 rounded-full bg-slate-800 border border-amber-400/30 object-cover" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-100 truncate">{{ authStore.user.name }}</p>
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-emerald-400"></span>
|
||||
<span class="text-xs text-slate-400 capitalize">{{ authStore.user.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<nav class="flex-1 space-y-1.5">
|
||||
<router-link
|
||||
to="/"
|
||||
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
|
||||
:class="$route.path === '/' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
|
||||
>
|
||||
<LayoutDashboard class="w-5 h-5" />
|
||||
<span>Dashboard</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/chores"
|
||||
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
|
||||
:class="$route.path === '/chores' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
|
||||
>
|
||||
<CheckSquare class="w-5 h-5" />
|
||||
<span>Chores List</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/reports"
|
||||
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
|
||||
:class="$route.path === '/reports' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
|
||||
>
|
||||
<BarChart3 class="w-5 h-5" />
|
||||
<span>Weekly Reports</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="authStore.isAdmin"
|
||||
to="/admin"
|
||||
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
|
||||
:class="$route.path === '/admin' ? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
|
||||
>
|
||||
<Shield class="w-5 h-5" />
|
||||
<span>Admin Console</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/profile"
|
||||
class="flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm transition-all duration-200"
|
||||
:class="$route.path === '/profile' ? 'bg-amber-500/15 text-amber-400 border border-amber-500/30 font-semibold' : 'text-slate-300 hover:bg-slate-800/60 hover:text-white'"
|
||||
>
|
||||
<User class="w-5 h-5" />
|
||||
<span>My Profile</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="mt-auto flex items-center space-x-3 px-3 py-2.5 rounded-xl font-medium text-sm text-rose-400 hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 transition-all duration-200 w-full"
|
||||
>
|
||||
<LogOut class="w-5 h-5" />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile Top Header Bar -->
|
||||
<header class="md:hidden glass-panel border-b border-slate-700/50 sticky top-0 z-30 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-8 h-8 rounded-lg bg-gradient-to-tr from-amber-500 to-emerald-400 flex items-center justify-center">
|
||||
<Sparkles class="w-4 h-4 text-slate-950 font-bold" />
|
||||
</div>
|
||||
<span class="font-bold text-base bg-gradient-to-r from-amber-400 to-emerald-400 bg-clip-text text-transparent">ChoreUS</span>
|
||||
</div>
|
||||
<div v-if="authStore.user" class="flex items-center space-x-2">
|
||||
<router-link to="/profile">
|
||||
<img :src="authStore.user.avatar_url || defaultAvatar" class="w-8 h-8 rounded-full border border-amber-400/40 object-cover" />
|
||||
</router-link>
|
||||
<button @click="handleLogout" class="p-1.5 text-slate-400 hover:text-rose-400">
|
||||
<LogOut class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Mobile Bottom Navigation Bar -->
|
||||
<nav class="md:hidden glass-panel border-t border-slate-700/50 fixed bottom-0 left-0 right-0 z-30 px-2 py-1.5 flex justify-around items-center">
|
||||
<router-link
|
||||
to="/"
|
||||
class="flex flex-col items-center p-2 rounded-xl transition-all"
|
||||
:class="$route.path === '/' ? 'text-amber-400 font-bold' : 'text-slate-400'"
|
||||
>
|
||||
<LayoutDashboard class="w-5 h-5" />
|
||||
<span class="text-[10px] mt-1">Home</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/chores"
|
||||
class="flex flex-col items-center p-2 rounded-xl transition-all"
|
||||
:class="$route.path === '/chores' ? 'text-amber-400 font-bold' : 'text-slate-400'"
|
||||
>
|
||||
<CheckSquare class="w-5 h-5" />
|
||||
<span class="text-[10px] mt-1">Chores</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/reports"
|
||||
class="flex flex-col items-center p-2 rounded-xl transition-all"
|
||||
:class="$route.path === '/reports' ? 'text-amber-400 font-bold' : 'text-slate-400'"
|
||||
>
|
||||
<BarChart3 class="w-5 h-5" />
|
||||
<span class="text-[10px] mt-1">Reports</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
v-if="authStore.isAdmin"
|
||||
to="/admin"
|
||||
class="flex flex-col items-center p-2 rounded-xl transition-all"
|
||||
:class="$route.path === '/admin' ? 'text-emerald-400 font-bold' : 'text-slate-400'"
|
||||
>
|
||||
<Shield class="w-5 h-5" />
|
||||
<span class="text-[10px] mt-1">Admin</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/profile"
|
||||
class="flex flex-col items-center p-2 rounded-xl transition-all"
|
||||
:class="$route.path === '/profile' ? 'text-amber-400 font-bold' : 'text-slate-400'"
|
||||
>
|
||||
<User class="w-5 h-5" />
|
||||
<span class="text-[10px] mt-1">Profile</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import {
|
||||
Sparkles,
|
||||
LayoutDashboard,
|
||||
CheckSquare,
|
||||
BarChart3,
|
||||
Shield,
|
||||
User,
|
||||
LogOut
|
||||
} from '@lucide/vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=FamilyUser';
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout();
|
||||
router.push('/login');
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import DashboardView from '../views/DashboardView.vue';
|
||||
import ChoresView from '../views/ChoresView.vue';
|
||||
import ReportsView from '../views/ReportsView.vue';
|
||||
import AdminView from '../views/AdminView.vue';
|
||||
import ProfileView from '../views/ProfileView.vue';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
meta: { public: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: DashboardView
|
||||
},
|
||||
{
|
||||
path: '/chores',
|
||||
name: 'chores',
|
||||
component: ChoresView
|
||||
},
|
||||
{
|
||||
path: '/reports',
|
||||
name: 'reports',
|
||||
component: ReportsView
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: AdminView,
|
||||
meta: { requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: ProfileView
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/'
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
if (!authStore.isAuthenticated && !to.meta.public) {
|
||||
return next({ name: 'login' });
|
||||
}
|
||||
|
||||
if (authStore.isAuthenticated && to.name === 'login') {
|
||||
return next({ name: 'dashboard' });
|
||||
}
|
||||
|
||||
if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
return next({ name: 'dashboard' });
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,37 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
export async function apiFetch(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('choreus_token');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Unauthenticated or token expired
|
||||
localStorage.removeItem('choreus_token');
|
||||
localStorage.removeItem('choreus_user');
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = data?.detail || `HTTP Error ${response.status}`;
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { apiFetch } from '../services/api';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(JSON.parse(localStorage.getItem('choreus_user') || 'null'));
|
||||
const token = ref(localStorage.getItem('choreus_token') || null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value);
|
||||
const isAdmin = computed(() => user.value?.role === 'admin');
|
||||
|
||||
async function demoLogin(email) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const data = await apiFetch('/auth/demo-login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
setAuthData(data.access_token, data.user);
|
||||
return data.user;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function googleLogin(credential, payload = {}) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const data = await apiFetch('/auth/google', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ credential, ...payload })
|
||||
});
|
||||
setAuthData(data.access_token, data.user);
|
||||
return data.user;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCurrentUser() {
|
||||
if (!token.value) return null;
|
||||
try {
|
||||
const userData = await apiFetch('/auth/me');
|
||||
user.value = userData;
|
||||
localStorage.setItem('choreus_user', JSON.stringify(userData));
|
||||
return userData;
|
||||
} catch (err) {
|
||||
logout();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(updates) {
|
||||
if (!user.value) return;
|
||||
try {
|
||||
const updatedUser = await apiFetch(`/users/${user.value.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
user.value = updatedUser;
|
||||
localStorage.setItem('choreus_user', JSON.stringify(updatedUser));
|
||||
return updatedUser;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function setAuthData(accessToken, userData) {
|
||||
token.value = accessToken;
|
||||
user.value = userData;
|
||||
localStorage.setItem('choreus_token', accessToken);
|
||||
localStorage.setItem('choreus_user', JSON.stringify(userData));
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
localStorage.removeItem('choreus_token');
|
||||
localStorage.removeItem('choreus_user');
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
demoLogin,
|
||||
googleLogin,
|
||||
fetchCurrentUser,
|
||||
updateProfile,
|
||||
logout
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { apiFetch } from '../services/api';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
export const useChoresStore = defineStore('chores', () => {
|
||||
const choreTypes = ref([]);
|
||||
const recentCompletions = ref([]);
|
||||
const weeklyReport = ref(null);
|
||||
const availableWeeks = ref([]);
|
||||
const usersList = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function fetchChoreTypes() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await apiFetch('/chores/types');
|
||||
choreTypes.value = data;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function completeChore(choreTypeId, notes = '') {
|
||||
try {
|
||||
const result = await apiFetch('/chores/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ chore_type_id: choreTypeId, notes })
|
||||
});
|
||||
|
||||
// Trigger celebratory confetti burst!
|
||||
confetti({
|
||||
particleCount: 80,
|
||||
spread: 70,
|
||||
origin: { y: 0.6 }
|
||||
});
|
||||
|
||||
// Refresh completions & report
|
||||
await fetchWeeklyReport();
|
||||
await fetchRecentCompletions();
|
||||
return result;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWeeklyReport(week = null) {
|
||||
try {
|
||||
const url = week ? `/reports/weekly?week=${week}` : '/reports/weekly';
|
||||
const data = await apiFetch(url);
|
||||
weeklyReport.value = data;
|
||||
return data;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAvailableWeeks() {
|
||||
try {
|
||||
const data = await apiFetch('/reports/weeks');
|
||||
availableWeeks.value = data.weeks || [];
|
||||
return data;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRecentCompletions(week = null, limit = 50) {
|
||||
try {
|
||||
const url = week ? `/chores/completions?week=${week}&limit=${limit}` : `/chores/completions?limit=${limit}`;
|
||||
const data = await apiFetch(url);
|
||||
recentCompletions.value = data;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllUsers() {
|
||||
try {
|
||||
const data = await apiFetch('/users');
|
||||
usersList.value = data;
|
||||
return data;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function createChoreType(choreData) {
|
||||
try {
|
||||
const newChore = await apiFetch('/chores/types', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(choreData)
|
||||
});
|
||||
await fetchChoreTypes();
|
||||
return newChore;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateChoreType(id, updates) {
|
||||
try {
|
||||
const updated = await apiFetch(`/chores/types/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
await fetchChoreTypes();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChoreType(id) {
|
||||
try {
|
||||
await apiFetch(`/chores/types/${id}`, { method: 'DELETE' });
|
||||
await fetchChoreTypes();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function createUserAccount(userData) {
|
||||
try {
|
||||
const newUser = await apiFetch('/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
await fetchAllUsers();
|
||||
return newUser;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUserAccount(id, updates) {
|
||||
try {
|
||||
const updated = await apiFetch(`/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
await fetchAllUsers();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUserAccount(id) {
|
||||
try {
|
||||
await apiFetch(`/users/${id}`, { method: 'DELETE' });
|
||||
await fetchAllUsers();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
choreTypes,
|
||||
recentCompletions,
|
||||
weeklyReport,
|
||||
availableWeeks,
|
||||
usersList,
|
||||
loading,
|
||||
error,
|
||||
fetchChoreTypes,
|
||||
completeChore,
|
||||
fetchWeeklyReport,
|
||||
fetchAvailableWeeks,
|
||||
fetchRecentCompletions,
|
||||
fetchAllUsers,
|
||||
createChoreType,
|
||||
updateChoreType,
|
||||
deleteChoreType,
|
||||
createUserAccount,
|
||||
updateUserAccount,
|
||||
deleteUserAccount
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--text-primary: #f8fafc;
|
||||
--star-amber: #f59e0b;
|
||||
--emerald-accent: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #0f172a;
|
||||
color: #f8fafc;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Custom Glassmorphism styles */
|
||||
.glass-panel {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
/* Glowing text & icons */
|
||||
.glow-amber {
|
||||
text-shadow: 0 0 12px rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.glow-emerald {
|
||||
text-shadow: 0 0 12px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
/* Custom scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(51, 65, 85, 0.8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 1);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
|
||||
<Shield class="w-8 h-8 text-emerald-400" />
|
||||
<span>Admin Control Console</span>
|
||||
</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Manage family accounts, star quotas, and configure chore definitions.</p>
|
||||
</div>
|
||||
|
||||
<!-- Section Switcher Tabs -->
|
||||
<div class="glass-panel p-1 rounded-2xl flex space-x-1 border border-slate-700/50">
|
||||
<button
|
||||
@click="activeSection = 'chores'"
|
||||
class="px-4 py-2 rounded-xl text-xs font-semibold transition-all capitalize"
|
||||
:class="activeSection === 'chores' ? 'bg-emerald-500 text-slate-950 font-bold shadow-md' : 'text-slate-400 hover:text-slate-200'"
|
||||
>
|
||||
Manage Chores
|
||||
</button>
|
||||
<button
|
||||
@click="activeSection = 'users'"
|
||||
class="px-4 py-2 rounded-xl text-xs font-semibold transition-all capitalize"
|
||||
:class="activeSection === 'users' ? 'bg-emerald-500 text-slate-950 font-bold shadow-md' : 'text-slate-400 hover:text-slate-200'"
|
||||
>
|
||||
Manage Users
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 1: MANAGE CHORE TYPES -->
|
||||
<div v-if="activeSection === 'chores'" class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
|
||||
<CheckSquare class="w-5 h-5 text-amber-400" />
|
||||
<span>Chore Definitions</span>
|
||||
</h2>
|
||||
|
||||
<button
|
||||
@click="openChoreModal()"
|
||||
class="bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold px-4 py-2 rounded-xl text-sm flex items-center space-x-1.5 shadow-md transition-all"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
<span>Add New Chore</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="chore in choresStore.choreTypes"
|
||||
:key="chore.id"
|
||||
class="glass-card rounded-2xl p-5 border border-slate-700/50 flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-slate-800 border border-slate-700 flex items-center justify-center text-amber-400">
|
||||
<ChoreIcon :name="chore.icon" class="w-5 h-5" />
|
||||
</div>
|
||||
<span class="text-[10px] font-bold px-2.5 py-0.5 rounded-full uppercase bg-slate-800 text-slate-300 border border-slate-700">
|
||||
{{ chore.recurrence }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="font-bold text-slate-100 text-base">{{ chore.title }}</h3>
|
||||
<p class="text-xs text-slate-400 mt-1 line-clamp-2">{{ chore.description || 'No description' }}</p>
|
||||
|
||||
<div class="flex items-center space-x-1 mt-3">
|
||||
<Star v-for="s in 5" :key="s" class="w-3.5 h-3.5" :class="s <= chore.star_reward ? 'fill-amber-400 text-amber-400' : 'text-slate-700'" />
|
||||
<span class="text-xs font-bold text-amber-400 ml-1">+{{ chore.star_reward }} Stars</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 mt-4 pt-3 border-t border-slate-800">
|
||||
<button @click="openChoreModal(chore)" class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold py-1.5 rounded-xl border border-slate-700 flex items-center justify-center space-x-1">
|
||||
<Edit2 class="w-3.5 h-3.5" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button @click="handleDeleteChore(chore.id)" class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold px-3 py-1.5 rounded-xl border border-rose-500/20 flex items-center justify-center">
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 2: MANAGE USER ACCOUNTS -->
|
||||
<div v-if="activeSection === 'users'" class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
|
||||
<Users class="w-5 h-5 text-emerald-400" />
|
||||
<span>Family Member Accounts</span>
|
||||
</h2>
|
||||
|
||||
<button
|
||||
@click="openUserModal()"
|
||||
class="bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold px-4 py-2 rounded-xl text-sm flex items-center space-x-1.5 shadow-md transition-all"
|
||||
>
|
||||
<UserPlus class="w-4 h-4" />
|
||||
<span>Add Family Account</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="user in choresStore.usersList"
|
||||
:key="user.id"
|
||||
class="glass-card rounded-2xl p-5 border border-slate-700/50 flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<div class="flex items-center space-x-3 mb-3">
|
||||
<img :src="user.avatar_url || defaultAvatar" class="w-12 h-12 rounded-full border border-slate-700 object-cover bg-slate-800" />
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-100 text-base flex items-center space-x-1.5">
|
||||
<span>{{ user.name }}</span>
|
||||
<span v-if="user.role === 'admin'" class="text-[10px] bg-emerald-500/20 text-emerald-300 font-normal px-2 py-0.5 rounded-full">Admin</span>
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400">{{ user.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900/60 rounded-xl p-3 border border-slate-800 flex justify-between items-center text-xs">
|
||||
<span class="text-slate-400 font-medium">Weekly Star Quota:</span>
|
||||
<span class="font-bold text-amber-400 text-sm">{{ user.weekly_star_quota }} Stars / week</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 mt-4 pt-3 border-t border-slate-800">
|
||||
<button @click="openUserModal(user)" class="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold py-1.5 rounded-xl border border-slate-700 flex items-center justify-center space-x-1">
|
||||
<Edit2 class="w-3.5 h-3.5" />
|
||||
<span>Edit Account</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="user.id !== authStore.user?.id"
|
||||
@click="handleDeleteUser(user.id)"
|
||||
class="bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 text-xs font-semibold px-3 py-1.5 rounded-xl border border-rose-500/20"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL 1: CHORE TYPE EDIT/CREATE -->
|
||||
<div v-if="showChoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
|
||||
<div class="glass-panel w-full max-w-md rounded-3xl p-6 border border-slate-700 shadow-2xl relative">
|
||||
<h2 class="text-lg font-bold text-slate-100 mb-4">{{ editingChoreId ? 'Edit Chore Type' : 'Create New Chore Type' }}</h2>
|
||||
|
||||
<div class="space-y-4 text-xs">
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Chore Title</label>
|
||||
<input v-model="choreForm.title" type="text" placeholder="E.g. Vacuum living room" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Description</label>
|
||||
<textarea v-model="choreForm.description" rows="2" placeholder="Details on how to perform the chore..." class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Star Reward (1-5 Stars)</label>
|
||||
<select v-model.number="choreForm.star_reward" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-amber-400 font-bold">
|
||||
<option :value="1">1 Star (Simple)</option>
|
||||
<option :value="2">2 Stars</option>
|
||||
<option :value="3">3 Stars (Medium)</option>
|
||||
<option :value="4">4 Stars</option>
|
||||
<option :value="5">5 Stars (Difficult)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Recurrence Schedule</label>
|
||||
<select v-model="choreForm.recurrence" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100 capitalize">
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="spontaneous">Spontaneous</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Icon Style</label>
|
||||
<select v-model="choreForm.icon" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100">
|
||||
<option value="Utensils">Utensils (Kitchen / Dishwasher)</option>
|
||||
<option value="Broom">Broom (Vacuum / Sweeping)</option>
|
||||
<option value="Trash2">Trash (Bins & Recycling)</option>
|
||||
<option value="Sparkles">Sparkles (Cleaning / Scrubbing)</option>
|
||||
<option value="Snowflake">Snowflake (Snow removal)</option>
|
||||
<option value="ShoppingBag">ShoppingBag (Groceries)</option>
|
||||
<option value="Wrench">Wrench (Maintenance)</option>
|
||||
<option value="Shirt">Shirt (Laundry)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3 mt-6">
|
||||
<button @click="showChoreModal = false" class="w-1/2 bg-slate-800 text-slate-300 py-2.5 rounded-xl border border-slate-700 font-semibold text-xs">Cancel</button>
|
||||
<button @click="saveChoreForm" class="w-1/2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold py-2.5 rounded-xl text-xs shadow-md">Save Chore</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL 2: USER EDIT/CREATE -->
|
||||
<div v-if="showUserModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
|
||||
<div class="glass-panel w-full max-w-md rounded-3xl p-6 border border-slate-700 shadow-2xl relative">
|
||||
<h2 class="text-lg font-bold text-slate-100 mb-4">{{ editingUserId ? 'Edit User Account' : 'Add New Family Member' }}</h2>
|
||||
|
||||
<div class="space-y-4 text-xs">
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Full Name</label>
|
||||
<input v-model="userForm.name" type="text" placeholder="E.g. Alex" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Email Address</label>
|
||||
<input v-model="userForm.email" :disabled="!!editingUserId" type="email" placeholder="alex@family.com" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100 disabled:opacity-50" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Account Role</label>
|
||||
<select v-model="userForm.role" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100">
|
||||
<option value="regular">Regular User</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Weekly Star Quota</label>
|
||||
<input v-model.number="userForm.weekly_star_quota" type="number" min="1" max="100" class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-amber-400 font-bold" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Custom Avatar URL (Optional)</label>
|
||||
<input v-model="userForm.avatar_url" type="text" placeholder="https://..." class="w-full bg-slate-900 border border-slate-700 rounded-xl p-2.5 text-slate-100" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3 mt-6">
|
||||
<button @click="showUserModal = false" class="w-1/2 bg-slate-800 text-slate-300 py-2.5 rounded-xl border border-slate-700 font-semibold text-xs">Cancel</button>
|
||||
<button @click="saveUserForm" class="w-1/2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-bold py-2.5 rounded-xl text-xs shadow-md">Save Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useChoresStore } from '../stores/chores';
|
||||
import ChoreIcon from '../components/ChoreIcon.vue';
|
||||
import {
|
||||
Shield,
|
||||
CheckSquare,
|
||||
Users,
|
||||
Plus,
|
||||
UserPlus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Star
|
||||
} from '@lucide/vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const choresStore = useChoresStore();
|
||||
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
|
||||
|
||||
const activeSection = ref('chores');
|
||||
|
||||
// Chore Modal state
|
||||
const showChoreModal = ref(false);
|
||||
const editingChoreId = ref(null);
|
||||
const choreForm = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
star_reward: 1,
|
||||
recurrence: 'daily',
|
||||
icon: 'Utensils'
|
||||
});
|
||||
|
||||
// User Modal state
|
||||
const showUserModal = ref(false);
|
||||
const editingUserId = ref(null);
|
||||
const userForm = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'regular',
|
||||
weekly_star_quota: 15,
|
||||
avatar_url: ''
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await choresStore.fetchChoreTypes();
|
||||
await choresStore.fetchAllUsers();
|
||||
});
|
||||
|
||||
function openChoreModal(chore = null) {
|
||||
if (chore) {
|
||||
editingChoreId.value = chore.id;
|
||||
choreForm.title = chore.title;
|
||||
choreForm.description = chore.description || '';
|
||||
choreForm.star_reward = chore.star_reward;
|
||||
choreForm.recurrence = chore.recurrence;
|
||||
choreForm.icon = chore.icon;
|
||||
} else {
|
||||
editingChoreId.value = null;
|
||||
choreForm.title = '';
|
||||
choreForm.description = '';
|
||||
choreForm.star_reward = 1;
|
||||
choreForm.recurrence = 'daily';
|
||||
choreForm.icon = 'Utensils';
|
||||
}
|
||||
showChoreModal.value = true;
|
||||
}
|
||||
|
||||
async function saveChoreForm() {
|
||||
if (!choreForm.title) return alert('Title is required');
|
||||
try {
|
||||
if (editingChoreId.value) {
|
||||
await choresStore.updateChoreType(editingChoreId.value, choreForm);
|
||||
} else {
|
||||
await choresStore.createChoreType(choreForm);
|
||||
}
|
||||
showChoreModal.value = false;
|
||||
} catch (err) {
|
||||
alert(err.message || 'Error saving chore');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteChore(id) {
|
||||
if (confirm('Are you sure you want to delete this chore type?')) {
|
||||
await choresStore.deleteChoreType(id);
|
||||
}
|
||||
}
|
||||
|
||||
function openUserModal(user = null) {
|
||||
if (user) {
|
||||
editingUserId.value = user.id;
|
||||
userForm.name = user.name;
|
||||
userForm.email = user.email;
|
||||
userForm.role = user.role;
|
||||
userForm.weekly_star_quota = user.weekly_star_quota;
|
||||
userForm.avatar_url = user.avatar_url || '';
|
||||
} else {
|
||||
editingUserId.value = null;
|
||||
userForm.name = '';
|
||||
userForm.email = '';
|
||||
userForm.role = 'regular';
|
||||
userForm.weekly_star_quota = 15;
|
||||
userForm.avatar_url = '';
|
||||
}
|
||||
showUserModal.value = true;
|
||||
}
|
||||
|
||||
async function saveUserForm() {
|
||||
if (!userForm.name || !userForm.email) return alert('Name and email are required');
|
||||
try {
|
||||
if (editingUserId.value) {
|
||||
await choresStore.updateUserAccount(editingUserId.value, {
|
||||
name: userForm.name,
|
||||
role: userForm.role,
|
||||
weekly_star_quota: userForm.weekly_star_quota,
|
||||
avatar_url: userForm.avatar_url || null
|
||||
});
|
||||
} else {
|
||||
await choresStore.createUserAccount(userForm);
|
||||
}
|
||||
showUserModal.value = false;
|
||||
} catch (err) {
|
||||
alert(err.message || 'Error saving user');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteUser(id) {
|
||||
if (confirm('Are you sure you want to delete this user account?')) {
|
||||
await choresStore.deleteUserAccount(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
|
||||
<CheckSquare class="w-8 h-8 text-amber-400" />
|
||||
<span>Available Chores</span>
|
||||
</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Select a household chore, complete it, and earn your star reward!</p>
|
||||
</div>
|
||||
|
||||
<!-- Recurrence Filter Tabs -->
|
||||
<div class="glass-panel p-1 rounded-2xl flex space-x-1 border border-slate-700/50 w-full sm:w-auto">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
@click="activeTab = tab.value"
|
||||
class="px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 capitalize flex-1 sm:flex-initial"
|
||||
:class="activeTab === tab.value ? 'bg-amber-500 text-slate-950 shadow-md font-bold' : 'text-slate-400 hover:text-slate-200'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Quick Filters -->
|
||||
<div class="relative">
|
||||
<Search class="w-5 h-5 absolute left-4 top-3.5 text-slate-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search chores by title or description..."
|
||||
class="w-full glass-panel border border-slate-700/60 rounded-2xl pl-12 pr-4 py-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-amber-400/60 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading / Empty State -->
|
||||
<div v-if="choresStore.loading" class="text-center py-12 text-slate-400">
|
||||
Loading family chores...
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredChores.length === 0" class="text-center py-12 glass-panel rounded-3xl border border-slate-800">
|
||||
<Sparkles class="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<p class="text-slate-300 font-semibold">No chores found</p>
|
||||
<p class="text-xs text-slate-500 mt-1">Try adjusting your filter or search query.</p>
|
||||
</div>
|
||||
|
||||
<!-- Chores Cards Grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<div
|
||||
v-for="chore in filteredChores"
|
||||
:key="chore.id"
|
||||
class="glass-card rounded-3xl p-6 border border-slate-700/50 flex flex-col justify-between group hover:border-amber-400/50 transition-all duration-300"
|
||||
>
|
||||
<div>
|
||||
<!-- Card Header & Badge -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-slate-800/80 border border-slate-700/80 flex items-center justify-center text-amber-400 group-hover:scale-110 group-hover:bg-amber-500/10 transition-all">
|
||||
<ChoreIcon :name="chore.icon" class="w-6 h-6" />
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="text-[11px] font-bold px-3 py-1 rounded-full uppercase tracking-wider border"
|
||||
:class="getRecurrenceBadgeStyle(chore.recurrence)"
|
||||
>
|
||||
{{ chore.recurrence }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Title & Description -->
|
||||
<h2 class="text-lg font-bold text-slate-100 group-hover:text-amber-400 transition-colors">
|
||||
{{ chore.title }}
|
||||
</h2>
|
||||
<p class="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
|
||||
{{ chore.description || 'No description specified.' }}
|
||||
</p>
|
||||
|
||||
<!-- Star Reward Display -->
|
||||
<div class="flex items-center space-x-1 my-4">
|
||||
<span class="text-xs text-slate-400 font-semibold mr-2">Reward:</span>
|
||||
<div class="flex items-center space-x-0.5">
|
||||
<Star
|
||||
v-for="s in 5"
|
||||
:key="s"
|
||||
class="w-4 h-4"
|
||||
:class="s <= chore.star_reward ? 'fill-amber-400 text-amber-400 glow-amber' : 'text-slate-700'"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-amber-400 ml-1.5">+{{ chore.star_reward }} Stars</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Execute Action Button -->
|
||||
<button
|
||||
@click="openCompleteModal(chore)"
|
||||
class="w-full mt-2 bg-slate-800 hover:bg-gradient-to-r hover:from-amber-500 hover:to-amber-600 hover:text-slate-950 text-amber-400 border border-amber-500/30 font-bold py-2.5 px-4 rounded-2xl flex items-center justify-center space-x-2 transition-all duration-200 shadow-md group-hover:border-amber-400"
|
||||
>
|
||||
<CheckCircle2 class="w-5 h-5" />
|
||||
<span>Mark Completed</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complete Chore Modal -->
|
||||
<div v-if="selectedChore" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md">
|
||||
<div class="glass-panel w-full max-w-md rounded-3xl p-6 border border-slate-700 shadow-2xl relative animate-in fade-in zoom-in-95 duration-200">
|
||||
<button @click="selectedChore = null" class="absolute top-4 right-4 text-slate-400 hover:text-white p-1">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div class="text-center mb-6">
|
||||
<div class="w-14 h-14 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-center justify-center mx-auto mb-3 text-amber-400">
|
||||
<ChoreIcon :name="selectedChore.icon" class="w-8 h-8" />
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-slate-100">{{ selectedChore.title }}</h2>
|
||||
<p class="text-xs text-slate-400 mt-1">{{ selectedChore.description }}</p>
|
||||
|
||||
<div class="inline-flex items-center space-x-1.5 bg-amber-500/15 border border-amber-500/30 px-3 py-1 rounded-full text-amber-300 font-bold text-sm mt-3">
|
||||
<Star class="w-4 h-4 fill-amber-300" />
|
||||
<span>Earn +{{ selectedChore.star_reward }} Stars</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-xs font-semibold text-slate-300 mb-2">Optional Notes or Proof:</label>
|
||||
<textarea
|
||||
v-model="completionNotes"
|
||||
rows="3"
|
||||
placeholder="E.g., Done! Cleaned under the couch too."
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-2xl p-3 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-amber-400"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
@click="selectedChore = null"
|
||||
class="w-1/2 bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold py-3 rounded-2xl border border-slate-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmCompletion"
|
||||
:disabled="submitting"
|
||||
class="w-1/2 bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-bold py-3 rounded-2xl shadow-lg shadow-amber-500/25 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<Sparkles class="w-5 h-5" />
|
||||
<span>Claim Stars!</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useChoresStore } from '../stores/chores';
|
||||
import ChoreIcon from '../components/ChoreIcon.vue';
|
||||
import {
|
||||
CheckSquare,
|
||||
Search,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
X
|
||||
} from '@lucide/vue';
|
||||
|
||||
const choresStore = useChoresStore();
|
||||
|
||||
const activeTab = ref('all');
|
||||
const searchQuery = ref('');
|
||||
const selectedChore = ref(null);
|
||||
const completionNotes = ref('');
|
||||
const submitting = ref(false);
|
||||
|
||||
const tabs = [
|
||||
{ label: 'All Chores', value: 'all' },
|
||||
{ label: 'Daily', value: 'daily' },
|
||||
{ label: 'Weekly', value: 'weekly' },
|
||||
{ label: 'Spontaneous', value: 'spontaneous' }
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
await choresStore.fetchChoreTypes();
|
||||
});
|
||||
|
||||
const filteredChores = computed(() => {
|
||||
let list = choresStore.choreTypes;
|
||||
|
||||
if (activeTab.value !== 'all') {
|
||||
list = list.filter(c => c.recurrence === activeTab.value);
|
||||
}
|
||||
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
list = list.filter(c => c.title.toLowerCase().includes(q) || (c.description && c.description.toLowerCase().includes(q)));
|
||||
}
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
function openCompleteModal(chore) {
|
||||
selectedChore.value = chore;
|
||||
completionNotes.value = '';
|
||||
}
|
||||
|
||||
async function confirmCompletion() {
|
||||
if (!selectedChore.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
await choresStore.completeChore(selectedChore.value.id, completionNotes.value);
|
||||
selectedChore.value = null;
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to record completion');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getRecurrenceBadgeStyle(recurrence) {
|
||||
switch (recurrence) {
|
||||
case 'daily':
|
||||
return 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30';
|
||||
case 'weekly':
|
||||
return 'bg-indigo-500/15 text-indigo-300 border-indigo-500/30';
|
||||
case 'spontaneous':
|
||||
return 'bg-amber-500/15 text-amber-300 border-amber-500/30';
|
||||
default:
|
||||
return 'bg-slate-700 text-slate-300 border-slate-600';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header Banner -->
|
||||
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50 bg-gradient-to-r from-slate-900 via-slate-900/90 to-amber-950/30 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 text-xs font-semibold text-amber-400 uppercase tracking-wider mb-1">
|
||||
<Calendar class="w-4 h-4" />
|
||||
<span>Week {{ currentWeekDisplay }} Overview</span>
|
||||
</div>
|
||||
<h1 class="text-2xl md:text-3xl font-extrabold text-slate-100">
|
||||
Welcome back, <span class="bg-gradient-to-r from-amber-400 to-emerald-400 bg-clip-text text-transparent">{{ authStore.user?.name }}</span>! ✨
|
||||
</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Here is how your family is progressing on household chores this week.</p>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
to="/chores"
|
||||
class="bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-bold px-5 py-3 rounded-2xl flex items-center space-x-2 shadow-lg shadow-amber-500/25 transition-all duration-200"
|
||||
>
|
||||
<PlusCircle class="w-5 h-5" />
|
||||
<span>Complete a Chore</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats Cards Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- My Weekly Quota Progress -->
|
||||
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">My Weekly Progress</span>
|
||||
<h2 class="text-2xl font-black text-amber-400 mt-1">
|
||||
{{ myProgress?.stars_earned || 0 }} <span class="text-sm font-medium text-slate-400">/ {{ authStore.user?.weekly_star_quota }} Stars</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
<Star class="w-6 h-6 fill-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-slate-800 rounded-full h-3 mb-2 overflow-hidden">
|
||||
<div
|
||||
class="bg-gradient-to-r from-amber-500 to-emerald-400 h-3 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${myProgressPercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 flex justify-between">
|
||||
<span>{{ myProgressPercentage }}% quota completed</span>
|
||||
<span class="text-amber-400 font-semibold">{{ myStarsRemaining }} stars remaining</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Family Total Stars -->
|
||||
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">Family Team Score</span>
|
||||
<h2 class="text-2xl font-black text-emerald-400 mt-1">
|
||||
{{ choresStore.weeklyReport?.total_stars_earned || 0 }} <span class="text-sm font-medium text-slate-400">Stars</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<Trophy class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-4">
|
||||
Total of <span class="text-slate-200 font-bold">{{ choresStore.weeklyReport?.total_completions || 0 }}</span> chores completed by family members this week!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Weekly Leader -->
|
||||
<div class="glass-card rounded-2xl p-5 border border-slate-700/50 sm:col-span-2 lg:col-span-1">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">Weekly Chore Champ</span>
|
||||
<h2 class="text-xl font-bold text-slate-100 mt-1 flex items-center space-x-2">
|
||||
<span>{{ topPerformer?.name || 'No Activity Yet' }}</span>
|
||||
<span v-if="topPerformer" class="text-xs px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 font-medium">1st Place 🏆</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full border border-amber-400/40 overflow-hidden bg-slate-800">
|
||||
<img :src="topPerformer?.avatar_url || defaultAvatar" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-4">
|
||||
Leading with <span class="text-amber-400 font-bold">{{ topPerformer?.stars_earned || 0 }} stars</span> logged!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Family Member Completion Progress Bars -->
|
||||
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
|
||||
<Users class="w-5 h-5 text-amber-400" />
|
||||
<span>Family Progress Dashboard</span>
|
||||
</h2>
|
||||
<p class="text-xs text-slate-400">Chore completion status for each family member</p>
|
||||
</div>
|
||||
<router-link to="/reports" class="text-xs font-semibold text-amber-400 hover:underline">
|
||||
View Detailed Reports →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="u in choresStore.weeklyReport?.user_progress || []"
|
||||
:key="u.user_id"
|
||||
class="glass-card rounded-2xl p-4 border border-slate-700/40 hover:border-slate-600 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-3">
|
||||
<img :src="u.avatar_url || defaultAvatar" class="w-10 h-10 rounded-full bg-slate-800 border border-slate-600 object-cover" />
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="font-bold text-sm text-slate-100">{{ u.name }}</span>
|
||||
<span v-if="u.user_id === authStore.user?.id" class="text-[10px] bg-amber-500/20 text-amber-300 font-medium px-2 py-0.5 rounded-full">You</span>
|
||||
<span v-if="u.role === 'admin'" class="text-[10px] bg-emerald-500/20 text-emerald-300 font-medium px-2 py-0.5 rounded-full">Admin</span>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400">{{ u.completions_count }} chores logged</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-extrabold text-amber-400 flex items-center justify-end space-x-1">
|
||||
<span>{{ u.stars_earned }} / {{ u.weekly_star_quota }}</span>
|
||||
<Star class="w-4 h-4 fill-amber-400 text-amber-400" />
|
||||
</div>
|
||||
<span class="text-xs text-slate-400">{{ u.percentage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="w-full bg-slate-800 rounded-full h-2.5 overflow-hidden">
|
||||
<div
|
||||
class="h-2.5 rounded-full transition-all duration-500"
|
||||
:class="u.percentage >= 100 ? 'bg-gradient-to-r from-emerald-500 to-teal-400' : 'bg-gradient-to-r from-amber-500 to-amber-400'"
|
||||
:style="{ width: `${Math.min(100, u.percentage)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Completed Chores Stream -->
|
||||
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-bold text-slate-100 flex items-center space-x-2">
|
||||
<Sparkles class="w-5 h-5 text-emerald-400" />
|
||||
<span>Recent Family Activity</span>
|
||||
</h2>
|
||||
<span class="text-xs text-slate-400">Live Activity Feed</span>
|
||||
</div>
|
||||
|
||||
<div v-if="choresStore.recentCompletions.length === 0" class="text-center py-8 text-slate-400 text-sm">
|
||||
No chores logged yet this week. Be the first to earn stars!
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="c in choresStore.recentCompletions.slice(0, 5)"
|
||||
:key="c.id"
|
||||
class="glass-card rounded-xl p-3 flex items-center justify-between border border-slate-800"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400">
|
||||
<ChoreIcon :name="c.chore_icon" class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-200">{{ c.chore_title }}</p>
|
||||
<p class="text-xs text-slate-400">Completed by <span class="text-slate-300 font-medium">{{ c.user_name }}</span> • {{ formatDate(c.completed_at) }}</p>
|
||||
<p v-if="c.notes" class="text-xs text-slate-400 italic mt-0.5">"{{ c.notes }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1 bg-amber-500/15 border border-amber-500/30 text-amber-300 text-xs font-bold px-2.5 py-1 rounded-full">
|
||||
<span>+{{ c.stars_earned }}</span>
|
||||
<Star class="w-3.5 h-3.5 fill-amber-300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useChoresStore } from '../stores/chores';
|
||||
import ChoreIcon from '../components/ChoreIcon.vue';
|
||||
import {
|
||||
Calendar,
|
||||
PlusCircle,
|
||||
Star,
|
||||
Trophy,
|
||||
Users,
|
||||
Sparkles
|
||||
} from '@lucide/vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const choresStore = useChoresStore();
|
||||
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
|
||||
|
||||
onMounted(async () => {
|
||||
await choresStore.fetchWeeklyReport();
|
||||
await choresStore.fetchRecentCompletions();
|
||||
});
|
||||
|
||||
const currentWeekDisplay = computed(() => {
|
||||
return choresStore.weeklyReport?.week_identifier || '';
|
||||
});
|
||||
|
||||
const myProgress = computed(() => {
|
||||
return choresStore.weeklyReport?.user_progress.find(u => u.user_id === authStore.user?.id);
|
||||
});
|
||||
|
||||
const myProgressPercentage = computed(() => {
|
||||
if (!myProgress.value) return 0;
|
||||
return myProgress.value.percentage;
|
||||
});
|
||||
|
||||
const myStarsRemaining = computed(() => {
|
||||
const quota = authStore.user?.weekly_star_quota || 15;
|
||||
const earned = myProgress.value?.stars_earned || 0;
|
||||
return Math.max(0, quota - earned);
|
||||
});
|
||||
|
||||
const topPerformer = computed(() => {
|
||||
if (!choresStore.weeklyReport?.user_progress?.length) return null;
|
||||
return choresStore.weeklyReport.user_progress[0];
|
||||
});
|
||||
|
||||
function formatDate(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ', ' + d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 flex flex-col justify-center items-center p-4 relative overflow-hidden">
|
||||
<!-- Ambient background glow -->
|
||||
<div class="absolute -top-40 -left-40 w-96 h-96 bg-amber-500/10 rounded-full blur-3xl pointer-events-none"></div>
|
||||
<div class="absolute -bottom-40 -right-40 w-96 h-96 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<div class="w-full max-w-md glass-panel rounded-3xl p-8 border border-slate-700/60 shadow-2xl shadow-slate-950/80 z-10">
|
||||
<!-- Logo & Welcome -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 rounded-2xl bg-gradient-to-tr from-amber-500 via-amber-400 to-emerald-400 flex items-center justify-center mx-auto mb-4 shadow-xl shadow-amber-500/25">
|
||||
<Sparkles class="w-9 h-9 text-slate-950 font-bold" />
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold tracking-tight bg-gradient-to-r from-amber-400 via-amber-200 to-emerald-400 bg-clip-text text-transparent">
|
||||
ChoreUS
|
||||
</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Family Chore & Reward Manager</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mb-4 p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-sm text-center">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Quick Demo Login Accounts -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 text-center">
|
||||
⚡ Quick Demo Login
|
||||
</h2>
|
||||
<div class="space-y-2.5">
|
||||
<button
|
||||
@click="handleDemoLogin('admin@choreus.app')"
|
||||
class="w-full glass-card hover:border-amber-400/50 p-3 rounded-2xl flex items-center justify-between text-left transition-all duration-200 group"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<img src="https://api.dicebear.com/7.x/bottts/svg?seed=SarahAdmin" class="w-10 h-10 rounded-full bg-slate-800 border border-amber-400/40" />
|
||||
<div>
|
||||
<p class="text-sm font-bold text-slate-100 group-hover:text-amber-400 transition-colors">Sarah (Admin)</p>
|
||||
<p class="text-xs text-slate-400">Parent / Administrator Account</p>
|
||||
</div>
|
||||
</div>
|
||||
<Shield class="w-5 h-5 text-amber-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="handleDemoLogin('leo@choreus.app')"
|
||||
class="w-full glass-card hover:border-amber-400/50 p-3 rounded-2xl flex items-center justify-between text-left transition-all duration-200 group"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<img src="https://api.dicebear.com/7.x/bottts/svg?seed=LeoKid" class="w-10 h-10 rounded-full bg-slate-800 border border-emerald-400/40" />
|
||||
<div>
|
||||
<p class="text-sm font-bold text-slate-100 group-hover:text-emerald-400 transition-colors">Leo (Kid)</p>
|
||||
<p class="text-xs text-slate-400">Regular Family Member (15 Stars Quota)</p>
|
||||
</div>
|
||||
</div>
|
||||
<User class="w-5 h-5 text-emerald-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="handleDemoLogin('maya@choreus.app')"
|
||||
class="w-full glass-card hover:border-amber-400/50 p-3 rounded-2xl flex items-center justify-between text-left transition-all duration-200 group"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<img src="https://api.dicebear.com/7.x/bottts/svg?seed=MayaKid" class="w-10 h-10 rounded-full bg-slate-800 border border-indigo-400/40" />
|
||||
<div>
|
||||
<p class="text-sm font-bold text-slate-100 group-hover:text-indigo-400 transition-colors">Maya (Kid)</p>
|
||||
<p class="text-xs text-slate-400">Regular Family Member (15 Stars Quota)</p>
|
||||
</div>
|
||||
</div>
|
||||
<User class="w-5 h-5 text-indigo-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Google OAuth Sign In Divider & Button -->
|
||||
<div class="relative mb-6">
|
||||
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-700/60"></div></div>
|
||||
<div class="relative flex justify-center text-xs uppercase"><span class="bg-slate-900 px-3 text-slate-400 font-medium">or continue with</span></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleGoogleMockLogin"
|
||||
class="w-full bg-slate-800 hover:bg-slate-700 text-slate-100 font-semibold py-3 px-4 rounded-2xl border border-slate-600/50 flex items-center justify-center space-x-3 transition-all duration-200 shadow-md"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" 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="#34A853" 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="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"/>
|
||||
<path fill="#EA4335" 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.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"/>
|
||||
</svg>
|
||||
<span>Sign in with Google</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { Sparkles, Shield, User } from '@lucide/vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const error = ref(null);
|
||||
|
||||
async function handleDemoLogin(email) {
|
||||
try {
|
||||
error.value = null;
|
||||
await authStore.demoLogin(email);
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Login failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogleMockLogin() {
|
||||
try {
|
||||
error.value = null;
|
||||
// Simulates Google OAuth flow for test / environment demonstration
|
||||
await authStore.googleLogin('google_oauth_token', {
|
||||
email: 'family_google_user@choreus.app',
|
||||
name: 'Google Family User',
|
||||
avatar_url: 'https://api.dicebear.com/7.x/bottts/svg?seed=GoogleUser'
|
||||
});
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Google Auth failed';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
|
||||
<User class="w-8 h-8 text-amber-400" />
|
||||
<span>My Profile & Settings</span>
|
||||
</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Configure your avatar, account details, and personal quotas.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="successMsg" class="p-3 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-sm text-center font-medium">
|
||||
{{ successMsg }}
|
||||
</div>
|
||||
|
||||
<!-- Profile Card -->
|
||||
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50 space-y-6">
|
||||
<!-- Avatar Section -->
|
||||
<div class="flex flex-col sm:flex-row items-center space-y-4 sm:space-y-0 sm:space-x-6 pb-6 border-b border-slate-800">
|
||||
<img :src="avatarUrl || defaultAvatar" class="w-24 h-24 rounded-full border-2 border-amber-400/50 shadow-xl object-cover bg-slate-800" />
|
||||
|
||||
<div class="space-y-2 text-center sm:text-left flex-1">
|
||||
<h2 class="text-xl font-bold text-slate-100">{{ authStore.user?.name }}</h2>
|
||||
<p class="text-xs text-slate-400">{{ authStore.user?.email }}</p>
|
||||
<div class="inline-flex items-center space-x-1 bg-amber-500/15 border border-amber-500/30 px-3 py-1 rounded-full text-xs font-semibold text-amber-300">
|
||||
<Shield class="w-3.5 h-3.5" />
|
||||
<span class="capitalize">{{ authStore.user?.role }} Account</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editable Profile Form -->
|
||||
<form @submit.prevent="handleSaveProfile" class="space-y-4 text-xs">
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Display Name</label>
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
required
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-2xl p-3 text-sm text-slate-100 focus:outline-none focus:border-amber-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Avatar Image URL</label>
|
||||
<input
|
||||
v-model="avatarUrl"
|
||||
type="text"
|
||||
placeholder="https://api.dicebear.com/..."
|
||||
class="w-full bg-slate-900 border border-slate-700 rounded-2xl p-3 text-sm text-slate-100 focus:outline-none focus:border-amber-400"
|
||||
/>
|
||||
<p class="text-[11px] text-slate-500 mt-1">Leave empty to use your default Google account avatar.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-300 mb-1">Weekly Star Quota</label>
|
||||
<input
|
||||
:value="authStore.user?.weekly_star_quota"
|
||||
disabled
|
||||
type="number"
|
||||
class="w-full bg-slate-900/50 border border-slate-800 rounded-2xl p-3 text-sm text-amber-400 font-bold opacity-70 cursor-not-allowed"
|
||||
/>
|
||||
<p class="text-[11px] text-slate-500 mt-1">Weekly star quotas are configured by family Administrators.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
class="w-full bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-bold py-3 rounded-2xl shadow-lg shadow-amber-500/25 transition-all text-sm mt-4 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<Save class="w-4 h-4" />
|
||||
<span>Save Profile Changes</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { User, Shield, Save } from '@lucide/vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
|
||||
|
||||
const name = ref('');
|
||||
const avatarUrl = ref('');
|
||||
const saving = ref(false);
|
||||
const successMsg = ref('');
|
||||
|
||||
onMounted(() => {
|
||||
if (authStore.user) {
|
||||
name.value = authStore.user.name || '';
|
||||
avatarUrl.value = authStore.user.avatar_url || '';
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSaveProfile() {
|
||||
saving.value = true;
|
||||
successMsg.value = '';
|
||||
try {
|
||||
await authStore.updateProfile({
|
||||
name: name.value,
|
||||
avatar_url: avatarUrl.value || null
|
||||
});
|
||||
successMsg.value = 'Profile updated successfully!';
|
||||
setTimeout(() => { successMsg.value = ''; }, 3000);
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to update profile');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header & Week Selector -->
|
||||
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-extrabold text-slate-100 flex items-center space-x-2">
|
||||
<BarChart3 class="w-8 h-8 text-amber-400" />
|
||||
<span>Weekly Chore Reports</span>
|
||||
</h1>
|
||||
<p class="text-slate-400 text-sm mt-1">Review family progress, completed tasks, and historical weekly achievements.</p>
|
||||
</div>
|
||||
|
||||
<!-- Week Dropdown Selector -->
|
||||
<div class="flex items-center space-x-2 w-full md:w-auto">
|
||||
<label class="text-xs font-semibold text-slate-400 whitespace-nowrap">Select Week:</label>
|
||||
<select
|
||||
v-model="selectedWeek"
|
||||
@change="onWeekChange"
|
||||
class="glass-card bg-slate-900 border border-slate-700/80 rounded-2xl px-4 py-2.5 text-sm text-amber-400 font-bold focus:outline-none focus:border-amber-400 w-full md:w-auto"
|
||||
>
|
||||
<option v-for="w in choresStore.availableWeeks" :key="w" :value="w">
|
||||
Week {{ w }} {{ w === currentWeek ? '(Current)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Metrics Grid -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">Total Stars Earned</span>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<span class="text-3xl font-black text-amber-400">{{ choresStore.weeklyReport?.total_stars_earned || 0 }}</span>
|
||||
<Star class="w-7 h-7 fill-amber-400 text-amber-400 glow-amber" />
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-2">Combined family total for Week {{ selectedWeek }}</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">Chores Completed</span>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<span class="text-3xl font-black text-emerald-400">{{ choresStore.weeklyReport?.total_completions || 0 }}</span>
|
||||
<CheckCircle2 class="w-7 h-7 text-emerald-400 glow-emerald" />
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-2">Logged task executions</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card rounded-2xl p-5 border border-slate-700/50">
|
||||
<span class="text-xs font-semibold text-slate-400 uppercase">My Stars Remaining</span>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<span class="text-3xl font-black text-indigo-400">{{ myStarsRemaining }}</span>
|
||||
<span class="text-xs font-bold text-slate-400">/ {{ authStore.user?.weekly_star_quota }} Quota</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-2">Needed to hit weekly quota</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Family Progress Breakdown Table/List -->
|
||||
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
|
||||
<h2 class="text-lg font-bold text-slate-100 mb-4 flex items-center space-x-2">
|
||||
<Users class="w-5 h-5 text-amber-400" />
|
||||
<span>Family Quota Status (Week {{ selectedWeek }})</span>
|
||||
</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm text-slate-300">
|
||||
<thead class="text-xs uppercase bg-slate-900/60 text-slate-400 border-b border-slate-700/60">
|
||||
<tr>
|
||||
<th class="px-4 py-3 rounded-l-xl">Family Member</th>
|
||||
<th class="px-4 py-3">Stars Earned</th>
|
||||
<th class="px-4 py-3">Weekly Quota</th>
|
||||
<th class="px-4 py-3">Stars Left</th>
|
||||
<th class="px-4 py-3 rounded-r-xl">Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800/60">
|
||||
<tr v-for="user in choresStore.weeklyReport?.user_progress || []" :key="user.user_id" class="hover:bg-slate-800/40">
|
||||
<td class="px-4 py-3.5 flex items-center space-x-3 font-semibold text-slate-100">
|
||||
<img :src="user.avatar_url || defaultAvatar" class="w-8 h-8 rounded-full border border-slate-700 object-cover" />
|
||||
<span>{{ user.name }}</span>
|
||||
<span v-if="user.user_id === authStore.user?.id" class="text-[10px] bg-amber-500/20 text-amber-300 px-2 py-0.5 rounded-full font-normal">You</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-extrabold text-amber-400">+{{ user.stars_earned }} Stars</td>
|
||||
<td class="px-4 py-3 text-slate-400">{{ user.weekly_star_quota }} Stars</td>
|
||||
<td class="px-4 py-3 font-semibold" :class="Math.max(0, user.weekly_star_quota - user.stars_earned) === 0 ? 'text-emerald-400' : 'text-slate-300'">
|
||||
{{ Math.max(0, user.weekly_star_quota - user.stars_earned) }} Left
|
||||
</td>
|
||||
<td class="px-4 py-3 w-48">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="w-full bg-slate-800 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-500"
|
||||
:class="user.percentage >= 100 ? 'bg-emerald-400' : 'bg-amber-400'"
|
||||
:style="{ width: `${Math.min(100, user.percentage)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-slate-400 font-mono">{{ user.percentage }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Chore Completion Log -->
|
||||
<div class="glass-panel rounded-3xl p-6 border border-slate-700/50">
|
||||
<h2 class="text-lg font-bold text-slate-100 mb-4 flex items-center space-x-2">
|
||||
<Clock class="w-5 h-5 text-emerald-400" />
|
||||
<span>Chore History Log</span>
|
||||
</h2>
|
||||
|
||||
<div v-if="choresStore.recentCompletions.length === 0" class="text-center py-8 text-slate-400 text-sm">
|
||||
No completed chores recorded for Week {{ selectedWeek }}.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="log in choresStore.recentCompletions"
|
||||
:key="log.id"
|
||||
class="glass-card rounded-2xl p-4 border border-slate-800 flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<img :src="log.user_avatar || defaultAvatar" class="w-9 h-9 rounded-full border border-slate-700 object-cover" />
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="font-bold text-sm text-slate-100">{{ log.chore_title }}</span>
|
||||
<span class="text-xs text-slate-400">by {{ log.user_name }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 flex items-center space-x-1 mt-0.5">
|
||||
<Calendar class="w-3.5 h-3.5 text-slate-500" />
|
||||
<span>{{ formatDate(log.completed_at) }}</span>
|
||||
<span v-if="log.notes" class="text-slate-300 italic ml-2">• "{{ log.notes }}"</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-1 bg-amber-500/15 border border-amber-500/30 text-amber-300 text-xs font-bold px-3 py-1 rounded-full whitespace-nowrap">
|
||||
<span>+{{ log.stars_earned }} Stars</span>
|
||||
<Star class="w-3.5 h-3.5 fill-amber-300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useChoresStore } from '../stores/chores';
|
||||
import {
|
||||
BarChart3,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
Users,
|
||||
Clock,
|
||||
Calendar
|
||||
} from '@lucide/vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const choresStore = useChoresStore();
|
||||
const defaultAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=DefaultUser';
|
||||
|
||||
const selectedWeek = ref('');
|
||||
const currentWeek = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
const weekData = await choresStore.fetchAvailableWeeks();
|
||||
if (weekData) {
|
||||
currentWeek.value = weekData.current_week;
|
||||
selectedWeek.value = weekData.current_week;
|
||||
}
|
||||
await loadWeekData(selectedWeek.value);
|
||||
});
|
||||
|
||||
async function onWeekChange() {
|
||||
await loadWeekData(selectedWeek.value);
|
||||
}
|
||||
|
||||
async function loadWeekData(week) {
|
||||
await choresStore.fetchWeeklyReport(week);
|
||||
await choresStore.fetchRecentCompletions(week);
|
||||
}
|
||||
|
||||
const myProgress = computed(() => {
|
||||
return choresStore.weeklyReport?.user_progress.find(u => u.user_id === authStore.user?.id);
|
||||
});
|
||||
|
||||
const myStarsRemaining = computed(() => {
|
||||
const quota = authStore.user?.weekly_star_quota || 15;
|
||||
const earned = myProgress.value?.stars_earned || 0;
|
||||
return Math.max(0, quota - earned);
|
||||
});
|
||||
|
||||
function formatDate(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) + ' at ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user