first prototype commit
This commit is contained in:
@@ -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