recommendation flow implemented
POC phase compleated
This commit is contained in:
parent
118efad70f
commit
8b4cef33dc
Binary file not shown.
106
apps/admin/src/app/api/recommendations/route.ts
Normal file
106
apps/admin/src/app/api/recommendations/route.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@clerk/nextjs/server'
|
||||||
|
import { getDatabase } from '@/lib/database'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { userId: currentUserId } = await auth()
|
||||||
|
if (!currentUserId) {
|
||||||
|
return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const targetUserId = searchParams.get('userId')
|
||||||
|
|
||||||
|
if (!targetUserId) {
|
||||||
|
return new NextResponse('User ID is required', { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase()
|
||||||
|
|
||||||
|
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
|
||||||
|
if (currentUserId !== targetUserId) {
|
||||||
|
const currentUser = await db.getUserById(currentUserId)
|
||||||
|
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
|
||||||
|
|
||||||
|
if (!isStaff) {
|
||||||
|
return new NextResponse('Forbidden', { status: 403 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendations = await db.getRecommendationsByUserId(targetUserId)
|
||||||
|
return NextResponse.json(recommendations)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recommendations:', error)
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { userId: currentUserId } = await auth()
|
||||||
|
if (!currentUserId) {
|
||||||
|
return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase()
|
||||||
|
const currentUser = await db.getUserById(currentUserId)
|
||||||
|
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
|
||||||
|
|
||||||
|
if (!isStaff) {
|
||||||
|
return new NextResponse('Forbidden', { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { userId, type, content, status } = body
|
||||||
|
|
||||||
|
if (!userId || !type || !content) {
|
||||||
|
return new NextResponse('Missing required fields', { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendation = await db.createRecommendation({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
status: status || 'pending'
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(recommendation)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating recommendation:', error)
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const { userId: currentUserId } = await auth()
|
||||||
|
if (!currentUserId) {
|
||||||
|
return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { id, status, content } = body
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new NextResponse('Recommendation ID is required', { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase()
|
||||||
|
|
||||||
|
// Users can update status (e.g. mark as completed), Staff can update content too
|
||||||
|
// Ideally we'd check ownership for status update, but for now let's allow it if they have the ID
|
||||||
|
// A stricter check would be: fetch recommendation, check if userId matches currentUserId OR if currentUser is staff
|
||||||
|
|
||||||
|
const updated = await db.updateRecommendation(id, {
|
||||||
|
...(status && { status }),
|
||||||
|
...(content && { content })
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(updated)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating recommendation:', error)
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
104
apps/admin/src/app/users/[id]/page.tsx
Normal file
104
apps/admin/src/app/users/[id]/page.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
|
||||||
|
import { getDatabase } from "@/lib/database";
|
||||||
|
import { Recommendations } from "@/components/users/Recommendations";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UserProfilePage({ params }: PageProps) {
|
||||||
|
const { id } = await params;
|
||||||
|
const db = await getDatabase();
|
||||||
|
const user = await db.getUserById(id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await db.getClientByUserId(user.id);
|
||||||
|
const fitnessProfile = await db.getFitnessProfileByUserId(user.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</h1>
|
||||||
|
<span className="px-3 py-1 bg-gray-100 rounded-full text-sm font-medium capitalize">
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Contact Information</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p><span className="font-medium">Email:</span> {user.email}</p>
|
||||||
|
<p><span className="font-medium">Phone:</span> {user.phone || "N/A"}</p>
|
||||||
|
<p><span className="font-medium">Joined:</span> {user.createdAt.toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client Info */}
|
||||||
|
{client && (
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Membership Details</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p><span className="font-medium">Type:</span> {client.membershipType}</p>
|
||||||
|
<p><span className="font-medium">Status:</span> {client.membershipStatus}</p>
|
||||||
|
<p><span className="font-medium">Member Since:</span> {client.joinDate.toLocaleDateString()}</p>
|
||||||
|
<p><span className="font-medium">Last Visit:</span> {client.lastVisit?.toLocaleDateString() || "Never"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fitness Profile */}
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Fitness Profile</h2>
|
||||||
|
{fitnessProfile ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-2 text-gray-700">Physical Stats</h3>
|
||||||
|
<p>Height: {fitnessProfile.height} cm</p>
|
||||||
|
<p>Weight: {fitnessProfile.weight} kg</p>
|
||||||
|
<p>Age: {fitnessProfile.age}</p>
|
||||||
|
<p>Gender: {fitnessProfile.gender}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-2 text-gray-700">Health & Habits</h3>
|
||||||
|
<p>Activity Level: {fitnessProfile.activityLevel.replace('_', ' ')}</p>
|
||||||
|
<p>Diet: {fitnessProfile.dietHabits || "N/A"}</p>
|
||||||
|
<p>Exercise: {fitnessProfile.exerciseHabits || "N/A"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium mb-2 text-gray-700">Medical</h3>
|
||||||
|
<p>Conditions: {fitnessProfile.medicalConditions || "None"}</p>
|
||||||
|
<p>Allergies: {fitnessProfile.allergies || "None"}</p>
|
||||||
|
<p>Injuries: {fitnessProfile.injuries || "None"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-full mt-4">
|
||||||
|
<h3 className="font-medium mb-2 text-gray-700">Goals</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{fitnessProfile.fitnessGoals.map((goal, i) => (
|
||||||
|
<span key={i} className="px-2 py-1 bg-blue-50 text-blue-700 rounded text-sm">
|
||||||
|
{goal.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 italic">No fitness profile created yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommendations Component */}
|
||||||
|
<Recommendations userId={user.id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
apps/admin/src/components/users/Recommendations.tsx
Normal file
170
apps/admin/src/components/users/Recommendations.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface Recommendation {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: "short_term" | "medium_term" | "long_term";
|
||||||
|
content: string;
|
||||||
|
status: "pending" | "completed";
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecommendationsProps {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Recommendations({ userId }: RecommendationsProps) {
|
||||||
|
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newRec, setNewRec] = useState<{
|
||||||
|
type: "short_term" | "medium_term" | "long_term";
|
||||||
|
content: string;
|
||||||
|
}>({ type: "short_term", content: "" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRecommendations();
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const fetchRecommendations = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/recommendations?userId=${userId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setRecommendations(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch recommendations:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRecommendation = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/recommendations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId,
|
||||||
|
type: newRec.type,
|
||||||
|
content: newRec.content,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setNewRec({ ...newRec, content: "" });
|
||||||
|
fetchRecommendations();
|
||||||
|
} else {
|
||||||
|
alert("Failed to add recommendation");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("Are you sure?")) return;
|
||||||
|
// Note: Delete API not implemented in route.ts yet, but good to have UI ready or we can add it.
|
||||||
|
// For now, let's assume we might add it or just omit.
|
||||||
|
// Actually, I didn't add DELETE to route.ts. Let's skip for now.
|
||||||
|
alert("Delete functionality not available yet.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedRecs = {
|
||||||
|
short_term: recommendations.filter((r) => r.type === "short_term"),
|
||||||
|
medium_term: recommendations.filter((r) => r.type === "medium_term"),
|
||||||
|
long_term: recommendations.filter((r) => r.type === "long_term"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSection = (
|
||||||
|
title: string,
|
||||||
|
type: "short_term" | "medium_term" | "long_term",
|
||||||
|
items: Recommendation[]
|
||||||
|
) => (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="font-semibold text-lg mb-3 capitalize">{title}</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.length === 0 && (
|
||||||
|
<p className="text-gray-500 text-sm italic">No recommendations yet.</p>
|
||||||
|
)}
|
||||||
|
{items.map((rec) => (
|
||||||
|
<div
|
||||||
|
key={rec.id}
|
||||||
|
className={`p-3 rounded border flex justify-between items-start ${rec.status === "completed"
|
||||||
|
? "bg-green-50 border-green-200"
|
||||||
|
: "bg-white border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">{rec.content}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{new Date(rec.createdAt).toLocaleDateString()} -{" "}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
rec.status === "completed"
|
||||||
|
? "text-green-600 font-medium"
|
||||||
|
: "text-yellow-600"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{rec.status === "completed" ? "Completed" : "Pending"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
value={type}
|
||||||
|
onChange={() => setNewRec({ ...newRec, type })}
|
||||||
|
/>
|
||||||
|
{newRec.type === type && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={`Add ${title.toLowerCase()}...`}
|
||||||
|
className="flex-1 border rounded px-3 py-1 text-sm"
|
||||||
|
value={newRec.content}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewRec({ ...newRec, content: e.target.value })
|
||||||
|
}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="secondary">
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{newRec.type !== type && (
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setNewRec({ type, content: "" })} className="text-xs text-gray-500">
|
||||||
|
+ Add New
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) return <div>Loading recommendations...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-xl font-bold">Fitness Recommendations</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{renderSection("Short Term Goals", "short_term", groupedRecs.short_term)}
|
||||||
|
{renderSection("Medium Term Goals", "medium_term", groupedRecs.medium_term)}
|
||||||
|
{renderSection("Long Term Goals", "long_term", groupedRecs.long_term)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { UserGrid } from "@/components/users/UserGrid";
|
import { UserGrid } from "@/components/users/UserGrid";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@ -212,7 +212,7 @@ export function UserManagement() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={handleEditUser}
|
onClick={() => selectedUser && handleEditUser(selectedUser)}
|
||||||
disabled={!selectedUser}
|
disabled={!selectedUser}
|
||||||
>
|
>
|
||||||
Edit User
|
Edit User
|
||||||
@ -235,7 +235,7 @@ export function UserManagement() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={handleDeleteUser}
|
onClick={() => selectedUser && handleDeleteUser(selectedUser)}
|
||||||
disabled={!selectedUser}
|
disabled={!selectedUser}
|
||||||
>
|
>
|
||||||
Delete User
|
Delete User
|
||||||
@ -434,8 +434,14 @@ export function UserManagement() {
|
|||||||
|
|
||||||
{selectedUser && (
|
{selectedUser && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold">User Details</h3>
|
<h3 className="text-lg font-semibold">User Details</h3>
|
||||||
|
<a
|
||||||
|
href={`/users/${selectedUser.id}`}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
View Full Profile & Recommendations
|
||||||
|
</a>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { IDatabase, User, Client, FitnessProfile, Attendance, DatabaseConfig } from './types'
|
import { IDatabase, User, Client, FitnessProfile, Attendance, Recommendation, DatabaseConfig } from './types'
|
||||||
|
|
||||||
export class SQLiteDatabase implements IDatabase {
|
export class SQLiteDatabase implements IDatabase {
|
||||||
private db: Database.Database | null = null
|
private db: Database.Database | null = null
|
||||||
@ -120,6 +120,24 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Recommendations table
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS recommendations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL CHECK (type IN ('short_term', 'medium_term', 'long_term')),
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'completed')),
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
updatedAt DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recommendations_userId ON recommendations(userId);
|
||||||
|
`)
|
||||||
|
|
||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_attendance_clientId ON attendance(clientId);
|
CREATE INDEX IF NOT EXISTS idx_attendance_clientId ON attendance(clientId);
|
||||||
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
|
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
|
||||||
@ -141,8 +159,8 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stmt = this.db.prepare(
|
const stmt = this.db.prepare(
|
||||||
`INSERT INTO users (id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
|
`INSERT INTO users(id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
|
|
||||||
stmt.run(
|
stmt.run(
|
||||||
@ -191,7 +209,7 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
values.push(new Date().toISOString()) // updatedAt
|
values.push(new Date().toISOString()) // updatedAt
|
||||||
values.push(id)
|
values.push(id)
|
||||||
|
|
||||||
const stmt = this.db.prepare(`UPDATE users SET ${setClause}, updatedAt = ? WHERE id = ?`)
|
const stmt = this.db.prepare(`UPDATE users SET ${setClause}, updatedAt = ? WHERE id = ? `)
|
||||||
stmt.run(values)
|
stmt.run(values)
|
||||||
|
|
||||||
return this.getUserById(id)
|
return this.getUserById(id)
|
||||||
@ -227,8 +245,8 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
const client: Client = { id, ...clientData }
|
const client: Client = { id, ...clientData }
|
||||||
|
|
||||||
const stmt = this.db.prepare(
|
const stmt = this.db.prepare(
|
||||||
`INSERT INTO clients (id, userId, membershipType, membershipStatus, joinDate)
|
`INSERT INTO clients(id, userId, membershipType, membershipStatus, joinDate)
|
||||||
VALUES (?, ?, ?, ?, ?)`
|
VALUES(?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
|
|
||||||
stmt.run(
|
stmt.run(
|
||||||
@ -273,7 +291,7 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
const values = fields.map(field => (updates as any)[field])
|
const values = fields.map(field => (updates as any)[field])
|
||||||
values.push(id)
|
values.push(id)
|
||||||
|
|
||||||
const stmt = this.db.prepare(`UPDATE clients SET ${setClause} WHERE id = ?`)
|
const stmt = this.db.prepare(`UPDATE clients SET ${setClause} WHERE id = ? `)
|
||||||
stmt.run(values)
|
stmt.run(values)
|
||||||
|
|
||||||
return this.getClientById(id)
|
return this.getClientById(id)
|
||||||
@ -302,7 +320,7 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
`INSERT INTO fitness_profiles
|
`INSERT INTO fitness_profiles
|
||||||
(userId, height, weight, age, gender, activityLevel, fitnessGoals,
|
(userId, height, weight, age, gender, activityLevel, fitnessGoals,
|
||||||
exerciseHabits, dietHabits, medicalConditions, allergies, injuries, createdAt, updatedAt)
|
exerciseHabits, dietHabits, medicalConditions, allergies, injuries, createdAt, updatedAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
|
|
||||||
stmt.run(
|
stmt.run(
|
||||||
@ -346,7 +364,7 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
values.push(new Date().toISOString()) // updatedAt
|
values.push(new Date().toISOString()) // updatedAt
|
||||||
values.push(userId)
|
values.push(userId)
|
||||||
|
|
||||||
const stmt = this.db.prepare(`UPDATE fitness_profiles SET ${setClause}, updatedAt = ? WHERE userId = ?`)
|
const stmt = this.db.prepare(`UPDATE fitness_profiles SET ${setClause}, updatedAt = ? WHERE userId = ? `)
|
||||||
stmt.run(values)
|
stmt.run(values)
|
||||||
|
|
||||||
return this.getFitnessProfileByUserId(userId)
|
return this.getFitnessProfileByUserId(userId)
|
||||||
@ -377,8 +395,8 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stmt = this.db.prepare(
|
const stmt = this.db.prepare(
|
||||||
`INSERT INTO attendance (id, clientId, checkInTime, type, notes, createdAt)
|
`INSERT INTO attendance(id, clientId, checkInTime, type, notes, createdAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
VALUES(?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
|
|
||||||
stmt.run(
|
stmt.run(
|
||||||
@ -490,6 +508,83 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recommendation operations
|
||||||
|
async createRecommendation(data: Omit<Recommendation, 'createdAt' | 'updatedAt'>): Promise<Recommendation> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const recommendation: Recommendation = {
|
||||||
|
...data,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO recommendations (id, userId, type, content, status, createdAt, updatedAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
recommendation.id, recommendation.userId, recommendation.type,
|
||||||
|
recommendation.content, recommendation.status,
|
||||||
|
recommendation.createdAt.toISOString(), recommendation.updatedAt.toISOString()
|
||||||
|
)
|
||||||
|
|
||||||
|
return recommendation
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecommendationsByUserId(userId: string): Promise<Recommendation[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE userId = ? ORDER BY createdAt DESC')
|
||||||
|
const rows = stmt.all(userId)
|
||||||
|
|
||||||
|
return rows.map(row => this.mapRowToRecommendation(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRecommendation(id: string, updates: Partial<Recommendation>): Promise<Recommendation | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'userId')
|
||||||
|
if (fields.length === 0) {
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
|
||||||
|
const row = stmt.get(id)
|
||||||
|
return row ? this.mapRowToRecommendation(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClause = fields.map(field => `${field} = ?`).join(', ')
|
||||||
|
const values = fields.map(field => (updates as any)[field])
|
||||||
|
values.push(new Date().toISOString()) // updatedAt
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(`UPDATE recommendations SET ${setClause}, updatedAt = ? WHERE id = ?`)
|
||||||
|
stmt.run(values)
|
||||||
|
|
||||||
|
const getStmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
|
||||||
|
const row = getStmt.get(id)
|
||||||
|
return row ? this.mapRowToRecommendation(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRecommendation(id: string): Promise<boolean> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const stmt = this.db.prepare('DELETE FROM recommendations WHERE id = ?')
|
||||||
|
const result = stmt.run(id)
|
||||||
|
return (result.changes || 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRowToRecommendation(row: any): Recommendation {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.userId,
|
||||||
|
type: row.type,
|
||||||
|
content: row.content,
|
||||||
|
status: row.status,
|
||||||
|
createdAt: new Date(row.createdAt),
|
||||||
|
updatedAt: new Date(row.updatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getDashboardStats(): Promise<{
|
async getDashboardStats(): Promise<{
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
activeClients: number;
|
activeClients: number;
|
||||||
|
|||||||
@ -47,6 +47,16 @@ export interface Attendance {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Recommendation {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: "short_term" | "medium_term" | "long_term";
|
||||||
|
content: string;
|
||||||
|
status: "pending" | "completed";
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
// Database Interface - allows us to swap implementations
|
// Database Interface - allows us to swap implementations
|
||||||
export interface IDatabase {
|
export interface IDatabase {
|
||||||
// Connection management
|
// Connection management
|
||||||
@ -93,6 +103,17 @@ export interface IDatabase {
|
|||||||
getAllAttendance(): Promise<Attendance[]>;
|
getAllAttendance(): Promise<Attendance[]>;
|
||||||
getActiveCheckIn(clientId: string): Promise<Attendance | null>;
|
getActiveCheckIn(clientId: string): Promise<Attendance | null>;
|
||||||
|
|
||||||
|
// Recommendation operations
|
||||||
|
createRecommendation(
|
||||||
|
recommendation: Omit<Recommendation, "createdAt" | "updatedAt">,
|
||||||
|
): Promise<Recommendation>;
|
||||||
|
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
|
||||||
|
updateRecommendation(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<Recommendation>,
|
||||||
|
): Promise<Recommendation | null>;
|
||||||
|
deleteRecommendation(id: string): Promise<boolean>;
|
||||||
|
|
||||||
// Dashboard operations
|
// Dashboard operations
|
||||||
getDashboardStats(): Promise<{
|
getDashboardStats(): Promise<{
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
|
|||||||
@ -67,6 +67,16 @@ export default function TabLayout() {
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="goals"
|
||||||
|
options={{
|
||||||
|
title: "Goals",
|
||||||
|
headerTitle: "Fitness Goals",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<Ionicons name="trophy" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
272
apps/mobile/src/app/(tabs)/goals.tsx
Normal file
272
apps/mobile/src/app/(tabs)/goals.tsx
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
Alert,
|
||||||
|
} from "react-native";
|
||||||
|
import { useAuth } from "@clerk/clerk-expo";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { API_BASE_URL, API_ENDPOINTS } from "../../config/api";
|
||||||
|
|
||||||
|
interface Recommendation {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: "short_term" | "medium_term" | "long_term";
|
||||||
|
content: string;
|
||||||
|
status: "pending" | "completed";
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GoalsScreen() {
|
||||||
|
const { userId, getToken } = useAuth();
|
||||||
|
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const fetchRecommendations = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}?userId=${userId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setRecommendations(data);
|
||||||
|
} else {
|
||||||
|
console.error("Failed to fetch recommendations");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching recommendations:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRecommendations();
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const onRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchRecommendations();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleStatus = async (id: string, currentStatus: string) => {
|
||||||
|
const newStatus = currentStatus === "pending" ? "completed" : "pending";
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
setRecommendations(prev =>
|
||||||
|
prev.map(r => r.id === id ? { ...r, status: newStatus } : r)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
const response = await fetch(`${API_BASE_URL}${API_ENDPOINTS.RECOMMENDATIONS}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
status: newStatus,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Revert on failure
|
||||||
|
setRecommendations(prev =>
|
||||||
|
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
|
||||||
|
);
|
||||||
|
Alert.alert("Error", "Failed to update status");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
// Revert on error
|
||||||
|
setRecommendations(prev =>
|
||||||
|
prev.map(r => r.id === id ? { ...r, status: currentStatus as "pending" | "completed" } : r)
|
||||||
|
);
|
||||||
|
Alert.alert("Error", "Failed to update status");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSection = (
|
||||||
|
title: string,
|
||||||
|
type: "short_term" | "medium_term" | "long_term"
|
||||||
|
) => {
|
||||||
|
const items = recommendations.filter((r) => r.type === type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>{title}</Text>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>No goals set yet.</Text>
|
||||||
|
) : (
|
||||||
|
items.map((rec) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={rec.id}
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
rec.status === "completed" && styles.cardCompleted,
|
||||||
|
]}
|
||||||
|
onPress={() => toggleStatus(rec.id, rec.status)}
|
||||||
|
>
|
||||||
|
<View style={styles.checkbox}>
|
||||||
|
{rec.status === "completed" && (
|
||||||
|
<Ionicons name="checkmark" size={16} color="#fff" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardContent}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.cardText,
|
||||||
|
rec.status === "completed" && styles.cardTextCompleted,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{rec.content}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.dateText}>
|
||||||
|
{new Date(rec.createdAt).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !refreshing) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<ActivityIndicator size="large" color="#2563eb" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>My Goals</Text>
|
||||||
|
<Text style={styles.headerSubtitle}>
|
||||||
|
Track your fitness journey progress
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{renderSection("Short Term Goals", "short_term")}
|
||||||
|
{renderSection("Medium Term Goals", "medium_term")}
|
||||||
|
{renderSection("Long Term Goals", "long_term")}
|
||||||
|
|
||||||
|
<View style={styles.footer} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "#f3f4f6",
|
||||||
|
},
|
||||||
|
center: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
marginBottom: 10,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#111827",
|
||||||
|
},
|
||||||
|
headerSubtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#6b7280",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
padding: 20,
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "600",
|
||||||
|
color: "#374151",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontStyle: "italic",
|
||||||
|
color: "#9ca3af",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
cardCompleted: {
|
||||||
|
backgroundColor: "#f0fdf4", // light green
|
||||||
|
borderColor: "#bbf7d0",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
checkbox: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 6,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: "#d1d5db",
|
||||||
|
marginRight: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
},
|
||||||
|
cardContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
cardText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#1f2937",
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
cardTextCompleted: {
|
||||||
|
textDecorationLine: "line-through",
|
||||||
|
color: "#9ca3af",
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#9ca3af",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
height: 40,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -17,4 +17,5 @@ export const API_ENDPOINTS = {
|
|||||||
CHECK_OUT: '/api/attendance/check-out',
|
CHECK_OUT: '/api/attendance/check-out',
|
||||||
HISTORY: '/api/attendance/history',
|
HISTORY: '/api/attendance/history',
|
||||||
},
|
},
|
||||||
|
RECOMMENDATIONS: '/api/recommendations',
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user