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