Compare commits

..

No commits in common. "3ab990858831a90f2ca9f6fea2799030ab6a4ed4" and "5d1d881f16ad1bd10f7c7993c8aa5886cf3f7864" have entirely different histories.

22 changed files with 696 additions and 1579 deletions

Binary file not shown.

View File

@ -1,36 +0,0 @@
[Approve] Updating recommendation a2a4d10a-463d-4648-b2a3-3b97fd053078 with: {"status":"approved","approvedAt":"2025-11-24T16:18:30.211Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for a2a4d10a-463d-4648-b2a3-3b97fd053078: {"changes":0,"lastInsertRowid":0}
[SQLite] Fetch after update for a2a4d10a-463d-4648-b2a3-3b97fd053078: Not Found
[Approve] Result for a2a4d10a-463d-4648-b2a3-3b97fd053078: Not Found
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:21:37.787Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":1}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:22:27.297Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":1}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:24:28.104Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:24:29.552Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:25:42.561Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:25:43.627Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":0}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"approved","approvedAt":"2025-11-24T16:27:02.858Z","approvedBy":"user_35fyZb2QT54VYzLfvx1cSIdcnKI"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":2}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success
[Approve] Updating recommendation fdacfa23-0116-452b-a40e-671583a6ce60 with: {"status":"rejected"}
[SQLite] Update result for fdacfa23-0116-452b-a40e-671583a6ce60: {"changes":1,"lastInsertRowid":2}
[SQLite] Fetch after update for fdacfa23-0116-452b-a40e-671583a6ce60: Found
[Approve] Result for fdacfa23-0116-452b-a40e-671583a6ce60: Success

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,6 @@
}, },
"dependencies": { "dependencies": {
"@clerk/nextjs": "^6.34.5", "@clerk/nextjs": "^6.34.5",
"@fitai/database": "file:../../packages/database",
"@fitai/shared": "file:../../packages/shared", "@fitai/shared": "file:../../packages/shared",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
@ -30,7 +29,6 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.44.7",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"next": "^16.0.1", "next": "^16.0.1",
"postcss": "^8.4.31", "postcss": "^8.4.31",

View File

@ -1,49 +0,0 @@
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
export async function POST(req: Request) {
try {
const { recommendationId, status, approvedBy } = await req.json();
if (!recommendationId || !status) {
return NextResponse.json(
{ error: "Recommendation ID and status are required" },
{ status: 400 }
);
}
const db = await getDatabase();
// Update recommendation status
const updates: any = {
status,
approvedAt: status === "approved" ? new Date() : undefined,
approvedBy: status === "approved" ? approvedBy : undefined,
};
// Remove undefined keys
Object.keys(updates).forEach(key => updates[key] === undefined && delete updates[key]);
const updatedRecommendation = await db.updateRecommendation(recommendationId, updates);
if (!updatedRecommendation) {
return NextResponse.json(
{ error: "Recommendation not found" },
{ status: 404 }
);
}
// If approved, create a notification for the user
// Note: IDatabase doesn't have createNotification yet, so we'll skip it for now
// or we need to add it to IDatabase/SQLiteDatabase
// For now, let's assume the notification is handled elsewhere or add it later
return NextResponse.json(updatedRecommendation);
} catch (error) {
console.error("Error approving recommendation:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -1,120 +0,0 @@
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
export async function POST(req: Request) {
try {
const { userId } = await req.json();
if (!userId) {
return NextResponse.json({ error: "User ID is required" }, { status: 400 });
}
const db = await getDatabase();
// Fetch fitness profile
const profile = await db.getFitnessProfileByUserId(userId);
if (!profile) {
return NextResponse.json(
{ error: "Fitness profile not found for this user" },
{ status: 404 }
);
}
// Construct prompt for Ollama
const prompt = `
You are a professional fitness trainer and nutritionist.
Generate a detailed daily recommendation for a user with the following profile:
- Height: ${profile.height} cm
- Weight: ${profile.weight} kg
- Age: ${profile.age}
- Gender: ${profile.gender}
- Goal: ${profile.fitnessGoals.join(", ")}
- Activity Level: ${profile.activityLevel}
- Medical Conditions: ${profile.medicalConditions || "None"}
- Injuries: ${profile.injuries || "None"}
Please provide the response in the following JSON format ONLY, no other text. Do not use markdown formatting or code blocks:
{
"recommendationText": "General advice and motivation for today.",
"activityPlan": "Detailed workout or activity plan for today.",
"dietPlan": "Detailed meal plan for today."
}
`;
// Call Ollama
const ollamaResponse = await fetch("http://localhost:11434/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gemma3:latest", // Make sure this model is pulled
prompt: prompt,
stream: false,
format: "json",
}),
});
if (!ollamaResponse.ok) {
console.error("Ollama API error:", await ollamaResponse.text());
return NextResponse.json(
{ error: "Failed to generate recommendation from AI service" },
{ status: 500 }
);
}
const aiData = await ollamaResponse.json();
console.log("Raw AI Response:", aiData.response);
let parsedResponse;
try {
// Helper to clean up the response
let cleanResponse = aiData.response.trim();
// Remove markdown code blocks if present
if (cleanResponse.startsWith("```json")) {
cleanResponse = cleanResponse.replace(/^```json\s*/, "").replace(/\s*```$/, "");
} else if (cleanResponse.startsWith("```")) {
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
}
// Find the first '{' and last '}' to extract the JSON object
const firstBrace = cleanResponse.indexOf("{");
const lastBrace = cleanResponse.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1) {
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
}
parsedResponse = JSON.parse(cleanResponse);
} catch (e) {
// Fallback if model doesn't return perfect JSON despite instruction
console.error("Failed to parse AI response:", aiData.response);
return NextResponse.json(
{ error: "Invalid response format from AI model" },
{ status: 500 }
);
}
// Save to database
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId: profile.userId, // Using userId as ID for now since it's 1:1
type: 'ai_plan',
content: parsedResponse.recommendationText,
activityPlan: parsedResponse.activityPlan,
dietPlan: parsedResponse.dietPlan,
status: 'pending'
});
return NextResponse.json(recommendation);
} catch (error) {
console.error("Error generating recommendation:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@ -12,20 +12,11 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const targetUserId = searchParams.get('userId') const targetUserId = searchParams.get('userId')
const db = await getDatabase()
// If no userId provided, check if staff and return all recommendations
if (!targetUserId) { if (!targetUserId) {
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (!isStaff) {
return new NextResponse('User ID is required', { status: 400 }) return new NextResponse('User ID is required', { status: 400 })
} }
const recommendations = await db.getAllRecommendations() const db = await getDatabase()
return NextResponse.json({ recommendations })
}
// Check permissions: Users can view their own, Admins/Trainers can view anyone's // Check permissions: Users can view their own, Admins/Trainers can view anyone's
if (currentUserId !== targetUserId) { if (currentUserId !== targetUserId) {
@ -61,25 +52,12 @@ export async function POST(request: Request) {
} }
const body = await request.json() const body = await request.json()
const { userId, fitnessProfileId, recommendationText, activityPlan, dietPlan, status, type, content } = body const { userId, type, content, status } = body
// Handle AI Plan (Legacy/Specific) if (!userId || !type || !content) {
if (recommendationText && activityPlan && dietPlan && fitnessProfileId) { return new NextResponse('Missing required fields', { status: 400 })
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId,
type: 'ai_plan',
content: recommendationText,
activityPlan,
dietPlan,
status: status || 'pending'
})
return NextResponse.json(recommendation)
} }
// Handle User Goal (Generic)
if (type && content) {
const recommendation = await db.createRecommendation({ const recommendation = await db.createRecommendation({
id: crypto.randomUUID(), id: crypto.randomUUID(),
userId, userId,
@ -87,11 +65,8 @@ export async function POST(request: Request) {
content, content,
status: status || 'pending' status: status || 'pending'
}) })
return NextResponse.json(recommendation) return NextResponse.json(recommendation)
}
return NextResponse.json('Missing required fields', { status: 400 })
} catch (error) { } catch (error) {
console.error('Error creating recommendation:', error) console.error('Error creating recommendation:', error)
return new NextResponse('Internal Server Error', { status: 500 }) return new NextResponse('Internal Server Error', { status: 500 })
@ -106,7 +81,7 @@ export async function PUT(request: Request) {
} }
const body = await request.json() const body = await request.json()
const { id, status, recommendationText, activityPlan, dietPlan, content } = body const { id, status, content } = body
if (!id) { if (!id) {
return new NextResponse('Recommendation ID is required', { status: 400 }) return new NextResponse('Recommendation ID is required', { status: 400 })
@ -114,12 +89,13 @@ export async function PUT(request: Request) {
const db = await getDatabase() 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, { const updated = await db.updateRecommendation(id, {
...(status && { status }), ...(status && { status }),
...(recommendationText && { content: recommendationText }), // Map legacy field ...(content && { content })
...(content && { content }),
...(activityPlan && { activityPlan }),
...(dietPlan && { dietPlan })
}) })
return NextResponse.json(updated) return NextResponse.json(updated)

View File

@ -74,7 +74,7 @@ export async function POST(request: NextRequest) {
} }
// Enforce Hierarchy // Enforce Hierarchy
const allowed: Record<string, string[]> = { const allowed = {
superAdmin: ["admin", "trainer", "client"], superAdmin: ["admin", "trainer", "client"],
admin: ["trainer", "client"], admin: ["trainer", "client"],
trainer: ["client"], trainer: ["client"],

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useUser } from "@clerk/nextjs"; import { useUser } from "@clerk/nextjs";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
interface UserProfile { interface UserProfile {
@ -55,7 +55,7 @@ export default function ProfilePage() {
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Profile</h1> <h1 className="text-2xl font-bold">Profile</h1>
<Button <Button
variant={isEditing ? "primary" : "secondary"} variant={isEditing ? "default" : "outline"}
onClick={() => setIsEditing(!isEditing)} onClick={() => setIsEditing(!isEditing)}
> >
{isEditing ? "Cancel" : "Edit Profile"} {isEditing ? "Cancel" : "Edit Profile"}

View File

@ -1,196 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useUser } from "@clerk/nextjs";
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
}
interface Recommendation {
id: string;
userId: string;
content: string;
activityPlan: string;
dietPlan: string;
status: string;
createdAt: Date;
}
export default function RecommendationsPage() {
const { user } = useUser();
const [users, setUsers] = useState<User[]>([]);
const [pendingRecommendations, setPendingRecommendations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
// Fetch users
const usersRes = await fetch("/api/users");
const usersData = await usersRes.json();
setUsers(usersData.users || []);
// Fetch pending recommendations
const recsRes = await fetch("/api/recommendations");
const recsData = await recsRes.json();
const allRecs = recsData.recommendations || [];
setPendingRecommendations(allRecs.filter((r: any) => r.status === 'pending'));
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
};
const handleGenerate = async (userId: string) => {
setGenerating(userId);
try {
const res = await fetch("/api/recommendations/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) {
const error = await res.json();
alert(`Error: ${error.error}`);
} else {
alert("Recommendation generated successfully!");
fetchData(); // Refresh data
}
} catch (error) {
console.error(error);
alert("Failed to generate recommendation.");
} finally {
setGenerating(null);
}
};
const handleApprove = async (recommendationId: string, status: "approved" | "rejected") => {
try {
const res = await fetch("/api/recommendations/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recommendationId,
status,
approvedBy: user?.id || "admin",
}),
});
if (!res.ok) {
const errorData = await res.json();
alert(`Failed to update status: ${errorData.error || 'Unknown error'}`);
} else {
fetchData(); // Refresh data
}
} catch (error) {
console.error(error);
alert("Error processing request");
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-xl">Loading...</div>
</div>
);
}
return (
<div className="container mx-auto py-10 px-4">
<h1 className="text-3xl font-bold mb-8">AI Recommendations</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Generate Section */}
<div>
<h2 className="text-2xl font-semibold mb-4">Generate Recommendations</h2>
<div className="bg-white shadow rounded-lg p-6">
<p className="mb-4 text-gray-600">
Select a user to generate a new daily recommendation.
</p>
<ul className="space-y-4">
{users.map((user) => (
<li key={user.id} className="flex items-center justify-between border-b pb-2">
<div>
<p className="font-medium">
{user.firstName} {user.lastName}
</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<button
onClick={() => handleGenerate(user.id)}
disabled={generating === user.id}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{generating === user.id ? "Generating..." : "Generate"}
</button>
</li>
))}
{users.length === 0 && (
<p className="text-gray-500 italic">No users found.</p>
)}
</ul>
</div>
</div>
{/* Pending Approvals Section */}
<div>
<h2 className="text-2xl font-semibold mb-4">Pending Approvals</h2>
<div className="bg-white shadow rounded-lg p-6">
{pendingRecommendations.length === 0 ? (
<p className="text-gray-500 italic">No pending recommendations.</p>
) : (
<ul className="space-y-6">
{pendingRecommendations.map((rec) => (
<li key={rec.id} className="border rounded p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold">For: User {rec.userId}</h3>
<span className="text-xs text-gray-500">
{new Date(rec.createdAt).toLocaleDateString()}
</span>
</div>
<div className="space-y-2 text-sm mb-4">
<div>
<span className="font-semibold">Advice:</span> {rec.content}
</div>
<div>
<span className="font-semibold">Activity:</span> {rec.activityPlan}
</div>
<div>
<span className="font-semibold">Diet:</span> {rec.dietPlan}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleApprove(rec.id, "approved")}
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700"
>
Approve
</button>
<button
onClick={() => handleApprove(rec.id, "rejected")}
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700"
>
Reject
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -3,10 +3,10 @@
import { type ReactElement } from "react"; import { type ReactElement } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Home, Users, BarChart3, User, Brain } from "lucide-react"; import { Home, Users, BarChart3, User } from "lucide-react";
import { SignedIn, UserButton } from "@clerk/nextjs"; import { SignedIn, UserButton } from "@clerk/nextjs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/button";
interface NavItem { interface NavItem {
href: string; href: string;
@ -25,11 +25,6 @@ const navItems: NavItem[] = [
label: "Clients", label: "Clients",
icon: Users, icon: Users,
}, },
{
href: "/recommendations",
label: "AI Recommendations",
icon: Brain,
},
{ {
href: "/analytics", href: "/analytics",
label: "Analytics", label: "Analytics",
@ -64,9 +59,10 @@ export function Navigation(): ReactElement {
> >
{navItems.map((item) => ( {navItems.map((item) => (
<li key={item.href}> <li key={item.href}>
<Link href={item.href} className="flex items-center gap-2">
<Button <Button
variant={pathname === item.href ? "primary" : "secondary"} asChild
variant={pathname === item.href ? "default" : "ghost"}
size="sm"
className={cn( className={cn(
"h-9 px-4 py-2", "h-9 px-4 py-2",
pathname === item.href && pathname === item.href &&
@ -74,10 +70,11 @@ export function Navigation(): ReactElement {
)} )}
aria-current={pathname === item.href ? "page" : undefined} aria-current={pathname === item.href ? "page" : undefined}
> >
<Link href={item.href} className="flex items-center gap-2">
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
<span>{item.label}</span> <span>{item.label}</span>
</Button>
</Link> </Link>
</Button>
</li> </li>
))} ))}
</ul> </ul>

View File

@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
import { UserGrowthChart } from '@/components/charts/UserGrowthChart' import { UserGrowthChart } from '@/components/charts/UserGrowthChart'
import { MembershipDistributionChart } from '@/components/charts/MembershipDistributionChart' import { MembershipDistributionChart } from '@/components/charts/MembershipDistributionChart'
import { RevenueChart } from '@/components/charts/RevenueChart' import { RevenueChart } from '@/components/charts/RevenueChart'
import { Card, CardHeader, CardContent } from '@/components/ui/card' import { Card, CardHeader, CardContent } from '@/components/ui/Card'
interface ChartData { interface ChartData {
label: string label: string

View File

@ -1,44 +0,0 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
export function GenerateButton({ userId }: { userId: string }) {
const [loading, setLoading] = useState(false);
const handleGenerate = async () => {
setLoading(true);
try {
const res = await fetch("/api/recommendations/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
});
if (!res.ok) {
const error = await res.json();
alert(`Error: ${error.error}`);
} else {
alert("Recommendation generated successfully! Check Pending Approvals.");
// In a real app, we'd revalidate the path or update state
window.location.reload();
}
} catch (error) {
console.error(error);
alert("Failed to generate recommendation.");
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleGenerate}
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate
</button>
);
}

View File

@ -1,117 +0,0 @@
"use client";
import { useState } from "react";
import { Loader2, Check, X } from "lucide-react";
type Recommendation = {
recommendation: {
id: string;
recommendationText: string;
activityPlan: string;
dietPlan: string;
createdAt: Date;
};
user: {
firstName: string;
lastName: string;
};
};
export function RecommendationList({
initialRecommendations,
}: {
initialRecommendations: Recommendation[];
}) {
const [recommendations, setRecommendations] = useState(initialRecommendations);
const [processingId, setProcessingId] = useState<string | null>(null);
const handleAction = async (id: string, status: "approved" | "rejected") => {
setProcessingId(id);
try {
const res = await fetch("/api/recommendations/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recommendationId: id,
status,
approvedBy: "admin_placeholder", // In real app, get from auth context
}),
});
if (!res.ok) {
alert("Failed to update status");
} else {
setRecommendations((prev) =>
prev.filter((item) => item.recommendation.id !== id)
);
}
} catch (error) {
console.error(error);
alert("Error processing request");
} finally {
setProcessingId(null);
}
};
if (recommendations.length === 0) {
return <p className="text-gray-500 italic">No pending recommendations.</p>;
}
return (
<ul className="space-y-6">
{recommendations.map(({ recommendation, user }) => (
<li key={recommendation.id} className="border rounded p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold">
For: {user.firstName} {user.lastName}
</h3>
<span className="text-xs text-gray-500">
{new Date(recommendation.createdAt).toLocaleDateString()}
</span>
</div>
<div className="space-y-2 text-sm mb-4">
<div>
<span className="font-semibold">Advice:</span>{" "}
{recommendation.recommendationText}
</div>
<div>
<span className="font-semibold">Activity:</span>{" "}
{recommendation.activityPlan}
</div>
<div>
<span className="font-semibold">Diet:</span> {recommendation.dietPlan}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleAction(recommendation.id, "approved")}
disabled={processingId === recommendation.id}
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50 flex justify-center items-center"
>
{processingId === recommendation.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-2 h-4 w-4" /> Approve
</>
)}
</button>
<button
onClick={() => handleAction(recommendation.id, "rejected")}
disabled={processingId === recommendation.id}
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700 disabled:opacity-50 flex justify-center items-center"
>
{processingId === recommendation.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<X className="mr-2 h-4 w-4" /> Reject
</>
)}
</button>
</div>
</li>
))}
</ul>
);
}

View File

@ -8,15 +8,13 @@ import {
CalendarCheck, CalendarCheck,
CreditCard, CreditCard,
Settings, Settings,
LogOut, LogOut
Brain
} from "lucide-react"; } from "lucide-react";
import { UserButton, useUser } from "@clerk/nextjs"; import { UserButton, useUser } from "@clerk/nextjs";
const menuItems = [ const menuItems = [
{ icon: LayoutDashboard, label: "Dashboard", href: "/" }, { icon: LayoutDashboard, label: "Dashboard", href: "/" },
{ icon: Users, label: "Users", href: "/users" }, { icon: Users, label: "Users", href: "/users" },
{ icon: Brain, label: "AI Recommendations", href: "/recommendations" },
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" }, { icon: CalendarCheck, label: "Attendance", href: "/attendance" },
{ icon: CreditCard, label: "Payments", href: "/payments" }, { icon: CreditCard, label: "Payments", href: "/payments" },
{ icon: Settings, label: "Settings", href: "/settings" }, { icon: Settings, label: "Settings", href: "/settings" },

View File

@ -7,11 +7,9 @@ import { Card, CardHeader, CardContent } from "@/components/ui/card";
interface Recommendation { interface Recommendation {
id: string; id: string;
userId: string; userId: string;
type: "short_term" | "medium_term" | "long_term" | "ai_plan"; type: "short_term" | "medium_term" | "long_term";
content: string; content: string;
activityPlan?: string; status: "pending" | "completed";
dietPlan?: string;
status: "pending" | "completed" | "approved" | "rejected";
createdAt: string; createdAt: string;
} }
@ -70,8 +68,15 @@ export function Recommendations({ userId }: RecommendationsProps) {
} }
}; };
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 = { const groupedRecs = {
ai_plan: recommendations.filter((r) => r.type === "ai_plan"),
short_term: recommendations.filter((r) => r.type === "short_term"), short_term: recommendations.filter((r) => r.type === "short_term"),
medium_term: recommendations.filter((r) => r.type === "medium_term"), medium_term: recommendations.filter((r) => r.type === "medium_term"),
long_term: recommendations.filter((r) => r.type === "long_term"), long_term: recommendations.filter((r) => r.type === "long_term"),
@ -79,7 +84,7 @@ export function Recommendations({ userId }: RecommendationsProps) {
const renderSection = ( const renderSection = (
title: string, title: string,
type: "short_term" | "medium_term" | "long_term" | "ai_plan", type: "short_term" | "medium_term" | "long_term",
items: Recommendation[] items: Recommendation[]
) => ( ) => (
<div className="mb-6"> <div className="mb-6">
@ -91,41 +96,34 @@ export function Recommendations({ userId }: RecommendationsProps) {
{items.map((rec) => ( {items.map((rec) => (
<div <div
key={rec.id} key={rec.id}
className={`p-3 rounded border flex justify-between items-start ${rec.status === "completed" || rec.status === "approved" className={`p-3 rounded border flex justify-between items-start ${rec.status === "completed"
? "bg-green-50 border-green-200" ? "bg-green-50 border-green-200"
: "bg-white border-gray-200" : "bg-white border-gray-200"
}`} }`}
> >
<div className="w-full"> <div>
<p className="text-sm font-medium">{rec.content}</p> <p className="text-sm">{rec.content}</p>
{rec.type === 'ai_plan' && ( <p className="text-xs text-gray-400 mt-1">
<div className="mt-2 text-xs text-gray-600 space-y-1">
{rec.activityPlan && <p><span className="font-semibold">Activity:</span> {rec.activityPlan}</p>}
{rec.dietPlan && <p><span className="font-semibold">Diet:</span> {rec.dietPlan}</p>}
</div>
)}
<p className="text-xs text-gray-400 mt-2">
{new Date(rec.createdAt).toLocaleDateString()} -{" "} {new Date(rec.createdAt).toLocaleDateString()} -{" "}
<span <span
className={ className={
rec.status === "completed" || rec.status === "approved" rec.status === "completed"
? "text-green-600 font-medium" ? "text-green-600 font-medium"
: "text-yellow-600" : "text-yellow-600"
} }
> >
{rec.status === "completed" ? "Completed" : rec.status === "approved" ? "Approved" : "Pending"} {rec.status === "completed" ? "Completed" : "Pending"}
</span> </span>
</p> </p>
</div> </div>
</div> </div>
))} ))}
</div> </div>
{type !== 'ai_plan' && (
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2"> <form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
<input <input
type="hidden" type="hidden"
value={type} value={type}
onChange={() => setNewRec({ ...newRec, type: type as any })} onChange={() => setNewRec({ ...newRec, type })}
/> />
{newRec.type === type && ( {newRec.type === type && (
<> <>
@ -145,12 +143,11 @@ export function Recommendations({ userId }: RecommendationsProps) {
</> </>
)} )}
{newRec.type !== type && ( {newRec.type !== type && (
<Button type="button" variant="secondary" onClick={() => setNewRec({ type: type as any, content: "" })} className="text-xs text-gray-500"> <Button type="button" variant="secondary" onClick={() => setNewRec({ type, content: "" })} className="text-xs text-gray-500">
+ Add New + Add New
</Button> </Button>
)} )}
</form> </form>
)}
</div> </div>
); );
@ -162,8 +159,7 @@ export function Recommendations({ userId }: RecommendationsProps) {
<h3 className="text-xl font-bold">Fitness Recommendations</h3> <h3 className="text-xl font-bold">Fitness Recommendations</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{renderSection("Daily AI Plan", "ai_plan", groupedRecs.ai_plan)}
{renderSection("Short Term Goals", "short_term", groupedRecs.short_term)} {renderSection("Short Term Goals", "short_term", groupedRecs.short_term)}
{renderSection("Medium Term Goals", "medium_term", groupedRecs.medium_term)} {renderSection("Medium Term Goals", "medium_term", groupedRecs.medium_term)}
{renderSection("Long Term Goals", "long_term", groupedRecs.long_term)} {renderSection("Long Term Goals", "long_term", groupedRecs.long_term)}

View File

@ -121,24 +121,16 @@ export class SQLiteDatabase implements IDatabase {
`) `)
// Recommendations table // Recommendations table
// Removed DROP TABLE to persist data. Schema is now stable.
// this.db.exec(`DROP TABLE IF EXISTS recommendations`)
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS recommendations ( CREATE TABLE IF NOT EXISTS recommendations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
userId TEXT NOT NULL, userId TEXT NOT NULL,
fitnessProfileId TEXT, type TEXT NOT NULL CHECK (type IN ('short_term', 'medium_term', 'long_term')),
type TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
activityPlan TEXT, status TEXT NOT NULL CHECK (status IN ('pending', 'completed')),
dietPlan TEXT,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'completed')),
createdAt DATETIME NOT NULL, createdAt DATETIME NOT NULL,
approvedAt DATETIME, updatedAt DATETIME NOT NULL,
approvedBy TEXT, FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (fitnessProfileId) REFERENCES fitness_profiles (userId)
) )
`) `)
@ -153,9 +145,7 @@ export class SQLiteDatabase implements IDatabase {
} }
// User operations // User operations
async createUser( async createUser(userData: Omit<User, 'createdAt' | 'updatedAt'>): Promise<User> {
userData: Omit<User, "createdAt" | "updatedAt" | "id"> & { id?: string },
): Promise<User> {
if (!this.db) throw new Error('Database not connected') if (!this.db) throw new Error('Database not connected')
const id = userData.id || Math.random().toString(36).substr(2, 9) const id = userData.id || Math.random().toString(36).substr(2, 9)
@ -519,29 +509,25 @@ export class SQLiteDatabase implements IDatabase {
} }
// Recommendation operations // Recommendation operations
async createRecommendation(data: Omit<Recommendation, 'createdAt' | 'approvedAt' | 'approvedBy'>): Promise<Recommendation> { async createRecommendation(data: Omit<Recommendation, 'createdAt' | 'updatedAt'>): Promise<Recommendation> {
if (!this.db) throw new Error('Database not connected') if (!this.db) throw new Error('Database not connected')
const now = new Date() const now = new Date()
const recommendation: Recommendation = { const recommendation: Recommendation = {
...data, ...data,
createdAt: now, createdAt: now,
status: data.status || 'pending' updatedAt: now
} }
const stmt = this.db.prepare( const stmt = this.db.prepare(
`INSERT INTO recommendations ( `INSERT INTO recommendations (id, userId, type, content, status, createdAt, updatedAt)
id, userId, fitnessProfileId, type, content, VALUES (?, ?, ?, ?, ?, ?, ?)`
activityPlan, dietPlan, status, createdAt
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
stmt.run( stmt.run(
recommendation.id, recommendation.userId, recommendation.fitnessProfileId, recommendation.id, recommendation.userId, recommendation.type,
recommendation.type, recommendation.content, recommendation.activityPlan, recommendation.content, recommendation.status,
recommendation.dietPlan, recommendation.status, recommendation.createdAt.toISOString(), recommendation.updatedAt.toISOString()
recommendation.createdAt.toISOString()
) )
return recommendation return recommendation
@ -556,15 +542,6 @@ export class SQLiteDatabase implements IDatabase {
return rows.map(row => this.mapRowToRecommendation(row)) return rows.map(row => this.mapRowToRecommendation(row))
} }
async getAllRecommendations(): Promise<Recommendation[]> {
if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM recommendations ORDER BY createdAt DESC')
const rows = stmt.all()
return rows.map(row => this.mapRowToRecommendation(row))
}
async updateRecommendation(id: string, updates: Partial<Recommendation>): Promise<Recommendation | null> { async updateRecommendation(id: string, updates: Partial<Recommendation>): Promise<Recommendation | null> {
if (!this.db) throw new Error('Database not connected') if (!this.db) throw new Error('Database not connected')
@ -576,13 +553,11 @@ export class SQLiteDatabase implements IDatabase {
} }
const setClause = fields.map(field => `${field} = ?`).join(', ') const setClause = fields.map(field => `${field} = ?`).join(', ')
const values = fields.map(field => { const values = fields.map(field => (updates as any)[field])
const val = (updates as any)[field] values.push(new Date().toISOString()) // updatedAt
return val instanceof Date ? val.toISOString() : val
})
values.push(id) values.push(id)
const stmt = this.db.prepare(`UPDATE recommendations SET ${setClause} WHERE id = ?`) const stmt = this.db.prepare(`UPDATE recommendations SET ${setClause}, updatedAt = ? WHERE id = ?`)
stmt.run(values) stmt.run(values)
const getStmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?') const getStmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
@ -602,15 +577,11 @@ export class SQLiteDatabase implements IDatabase {
return { return {
id: row.id, id: row.id,
userId: row.userId, userId: row.userId,
fitnessProfileId: row.fitnessProfileId,
type: row.type, type: row.type,
content: row.content, content: row.content,
activityPlan: row.activityPlan,
dietPlan: row.dietPlan,
status: row.status, status: row.status,
createdAt: new Date(row.createdAt), createdAt: new Date(row.createdAt),
approvedAt: row.approvedAt ? new Date(row.approvedAt) : undefined, updatedAt: new Date(row.updatedAt)
approvedBy: row.approvedBy
} }
} }

View File

@ -50,15 +50,11 @@ export interface Attendance {
export interface Recommendation { export interface Recommendation {
id: string; id: string;
userId: string; userId: string;
fitnessProfileId?: string; type: "short_term" | "medium_term" | "long_term";
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
content: string; content: string;
activityPlan?: string; status: "pending" | "completed";
dietPlan?: string;
status: "pending" | "approved" | "rejected" | "completed";
createdAt: Date; createdAt: Date;
approvedAt?: Date; updatedAt: Date;
approvedBy?: string;
} }
// Database Interface - allows us to swap implementations // Database Interface - allows us to swap implementations
@ -68,9 +64,7 @@ export interface IDatabase {
disconnect(): Promise<void>; disconnect(): Promise<void>;
// User operations // User operations
createUser( createUser(user: Omit<User, "createdAt" | "updatedAt">): Promise<User>;
user: Omit<User, "createdAt" | "updatedAt" | "id"> & { id?: string },
): Promise<User>;
getUserById(id: string): Promise<User | null>; getUserById(id: string): Promise<User | null>;
getUserByEmail(email: string): Promise<User | null>; getUserByEmail(email: string): Promise<User | null>;
getAllUsers(): Promise<User[]>; getAllUsers(): Promise<User[]>;
@ -111,10 +105,9 @@ export interface IDatabase {
// Recommendation operations // Recommendation operations
createRecommendation( createRecommendation(
recommendation: Omit<Recommendation, "createdAt" | "approvedAt" | "approvedBy">, recommendation: Omit<Recommendation, "createdAt" | "updatedAt">,
): Promise<Recommendation>; ): Promise<Recommendation>;
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>; getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
getAllRecommendations(): Promise<Recommendation[]>;
updateRecommendation( updateRecommendation(
id: string, id: string,
updates: Partial<Recommendation>, updates: Partial<Recommendation>,

View File

@ -1,5 +1,5 @@
export const API_BASE_URL = __DEV__ export const API_BASE_URL = __DEV__
? 'https://0ccbc9f6f846.ngrok-free.app' ? 'https://f0a9b87c3831.ngrok-free.app'
: 'https://your-production-url.com' : 'https://your-production-url.com'
export const API_ENDPOINTS = { export const API_ENDPOINTS = {

Binary file not shown.

View File

@ -6,4 +6,3 @@ const sqlite = new Database('./fitai.db')
export const db = drizzle(sqlite, { schema }) export const db = drizzle(sqlite, { schema })
export * from './schema' export * from './schema'
export { eq, and, or, desc, asc } from 'drizzle-orm'

View File

@ -145,35 +145,6 @@ export const fitnessProfiles = sqliteTable("fitness_profiles", {
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
}); });
export const recommendations = sqliteTable("recommendations", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
fitnessProfileId: text("fitness_profile_id")
.notNull()
.references(() => fitnessProfiles.id, { onDelete: "cascade" }),
recommendationText: text("recommendation_text").notNull(),
activityPlan: text("activity_plan").notNull(),
dietPlan: text("diet_plan").notNull(),
status: text("status", {
enum: ["pending", "approved", "rejected"],
})
.notNull()
.default("pending"),
generatedAt: integer("generated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
approvedAt: integer("approved_at", { mode: "timestamp" }),
approvedBy: text("approved_by"), // User ID of admin/trainer
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export type User = typeof users.$inferSelect; export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert; export type NewUser = typeof users.$inferInsert;
export type Client = typeof clients.$inferSelect; export type Client = typeof clients.$inferSelect;
@ -186,5 +157,3 @@ export type Notification = typeof notifications.$inferSelect;
export type NewNotification = typeof notifications.$inferInsert; export type NewNotification = typeof notifications.$inferInsert;
export type FitnessProfile = typeof fitnessProfiles.$inferSelect; export type FitnessProfile = typeof fitnessProfiles.$inferSelect;
export type NewFitnessProfile = typeof fitnessProfiles.$inferInsert; export type NewFitnessProfile = typeof fitnessProfiles.$inferInsert;
export type Recommendation = typeof recommendations.$inferSelect;
export type NewRecommendation = typeof recommendations.$inferInsert;