3 and 4 completed
This commit is contained in:
1 parent
3573709d99
commit
97436c6823
21 files changed
+2168
-558
No files matched your search
Binary file not shown.
@@ -6,7 +6,7 @@ import log from "@/lib/logger";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { userId, useExternalModel } = await req.json();
|
||||
const { userId, useExternalModel, modelProvider } = await req.json();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
@@ -43,7 +43,78 @@ export async function POST(req: Request) {
|
||||
|
||||
let parsedResponse;
|
||||
|
||||
if (useExternalModel) {
|
||||
// Determine which AI provider to use
|
||||
const provider =
|
||||
modelProvider || (useExternalModel ? "deepseek" : "ollama");
|
||||
|
||||
if (provider === "openai") {
|
||||
// Use OpenAI
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!openaiApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "OpenAI API key not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
log.debug("Using OpenAI model", { userId });
|
||||
|
||||
const openaiResponse = await fetch(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
'You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks. The response must have this exact structure: {"recommendationText": "string", "activityPlan": "string", "dietPlan": "string"}',
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 1500,
|
||||
response_format: { type: "json_object" },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!openaiResponse.ok) {
|
||||
const errorText = await openaiResponse.text();
|
||||
log.error("OpenAI API request failed", new Error(errorText), {
|
||||
status: openaiResponse.status,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate recommendation from OpenAI" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const openaiData = await openaiResponse.json();
|
||||
log.debug("Received OpenAI response", { openaiData });
|
||||
|
||||
try {
|
||||
const content = openaiData.choices[0].message.content;
|
||||
parsedResponse = JSON.parse(content);
|
||||
} catch (e) {
|
||||
log.error("Failed to parse OpenAI response", e, {
|
||||
response: openaiData,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid response format from OpenAI" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
} else if (provider === "deepseek") {
|
||||
// Use DeepSeek AI
|
||||
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
|
||||
|
||||
@@ -203,11 +274,12 @@ export async function POST(req: Request) {
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
fitnessProfileId: profile.id,
|
||||
type: "ai_plan",
|
||||
recommendationText: parsedResponse.recommendationText,
|
||||
activityPlan: parsedResponse.activityPlan,
|
||||
dietPlan: parsedResponse.dietPlan,
|
||||
status: "pending",
|
||||
generatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
return NextResponse.json(recommendation);
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import Database from "better-sqlite3";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
const db = new Database("./data/fitai.db");
|
||||
|
||||
interface WeeklyStats {
|
||||
week: string; // ISO week start date
|
||||
checkIns: number;
|
||||
goalsCompleted: number;
|
||||
avgProgress: number;
|
||||
}
|
||||
|
||||
interface GoalStats {
|
||||
total: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
avgProgress: number;
|
||||
byType: Record<string, number>;
|
||||
}
|
||||
|
||||
interface AttendanceStats {
|
||||
totalCheckIns: number;
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
thisWeek: number;
|
||||
thisMonth: number;
|
||||
recentCheckIns: Array<{
|
||||
id: string;
|
||||
checkInTime: string;
|
||||
checkOutTime: string | null;
|
||||
type: string;
|
||||
duration?: number; // in minutes
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DashboardStatistics {
|
||||
goals: GoalStats;
|
||||
attendance: AttendanceStats;
|
||||
weeklyTrend: WeeklyStats[];
|
||||
}
|
||||
|
||||
// GET - Fetch dashboard statistics for authenticated user
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
log.debug("Fetching statistics for user", { userId });
|
||||
|
||||
// Get goal statistics
|
||||
const goals = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
goal_type as goalType,
|
||||
status,
|
||||
progress,
|
||||
target_value as targetValue,
|
||||
current_value as currentValue
|
||||
FROM fitness_goals
|
||||
WHERE user_id = ?`,
|
||||
)
|
||||
.all(userId) as Array<{
|
||||
goalType: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
targetValue: number | null;
|
||||
currentValue: number | null;
|
||||
}>;
|
||||
|
||||
const activeGoals = goals.filter((g) => g.status === "active");
|
||||
const completedGoals = goals.filter((g) => g.status === "completed");
|
||||
|
||||
const goalsByType: Record<string, number> = {};
|
||||
goals.forEach((goal) => {
|
||||
goalsByType[goal.goalType] = (goalsByType[goal.goalType] || 0) + 1;
|
||||
});
|
||||
|
||||
const avgProgress =
|
||||
activeGoals.length > 0
|
||||
? activeGoals.reduce((sum, g) => sum + (g.progress || 0), 0) /
|
||||
activeGoals.length
|
||||
: 0;
|
||||
|
||||
const goalStats: GoalStats = {
|
||||
total: goals.length,
|
||||
active: activeGoals.length,
|
||||
completed: completedGoals.length,
|
||||
avgProgress: Math.round(avgProgress),
|
||||
byType: goalsByType,
|
||||
};
|
||||
|
||||
// Get attendance statistics
|
||||
const attendanceRecords = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
id,
|
||||
check_in_time as checkInTime,
|
||||
check_out_time as checkOutTime,
|
||||
type
|
||||
FROM attendance
|
||||
WHERE user_id = ?
|
||||
ORDER BY check_in_time DESC`,
|
||||
)
|
||||
.all(userId) as Array<{
|
||||
id: string;
|
||||
checkInTime: string;
|
||||
checkOutTime: string | null;
|
||||
type: string;
|
||||
}>;
|
||||
|
||||
const totalCheckIns = attendanceRecords.length;
|
||||
|
||||
// Get recent check-ins (last 10)
|
||||
const recentCheckIns = attendanceRecords.slice(0, 10).map((record) => {
|
||||
let duration: number | undefined;
|
||||
if (record.checkOutTime) {
|
||||
const checkIn = new Date(record.checkInTime).getTime();
|
||||
const checkOut = new Date(record.checkOutTime).getTime();
|
||||
duration = Math.round((checkOut - checkIn) / (1000 * 60)); // minutes
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
duration,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate this week's check-ins
|
||||
const now = new Date();
|
||||
const startOfWeek = new Date(now);
|
||||
startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
|
||||
startOfWeek.setHours(0, 0, 0, 0);
|
||||
|
||||
const thisWeekCheckIns = attendanceRecords.filter(
|
||||
(r) => new Date(r.checkInTime) >= startOfWeek,
|
||||
).length;
|
||||
|
||||
// Calculate this month's check-ins
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const thisMonthCheckIns = attendanceRecords.filter(
|
||||
(r) => new Date(r.checkInTime) >= startOfMonth,
|
||||
).length;
|
||||
|
||||
// Calculate current streak (consecutive days with check-ins)
|
||||
let currentStreak = 0;
|
||||
let longestStreak = 0;
|
||||
let tempStreak = 0;
|
||||
let lastDate: Date | null = null;
|
||||
|
||||
const sortedRecords = [...attendanceRecords].sort(
|
||||
(a, b) =>
|
||||
new Date(b.checkInTime).getTime() - new Date(a.checkInTime).getTime(),
|
||||
);
|
||||
|
||||
const uniqueDays = new Set<string>();
|
||||
sortedRecords.forEach((record) => {
|
||||
const date = new Date(record.checkInTime);
|
||||
const dateStr = date.toISOString().split("T")[0];
|
||||
uniqueDays.add(dateStr);
|
||||
});
|
||||
|
||||
const sortedDays = Array.from(uniqueDays).sort().reverse();
|
||||
|
||||
for (let i = 0; i < sortedDays.length; i++) {
|
||||
const currentDate = new Date(sortedDays[i]);
|
||||
|
||||
if (lastDate === null) {
|
||||
tempStreak = 1;
|
||||
currentStreak = 1;
|
||||
} else {
|
||||
const dayDiff = Math.floor(
|
||||
(lastDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
if (dayDiff === 1) {
|
||||
tempStreak++;
|
||||
if (i === 0 || currentStreak > 0) {
|
||||
currentStreak = tempStreak;
|
||||
}
|
||||
} else {
|
||||
if (currentStreak === 0) {
|
||||
currentStreak = 0;
|
||||
}
|
||||
tempStreak = 1;
|
||||
}
|
||||
}
|
||||
|
||||
longestStreak = Math.max(longestStreak, tempStreak);
|
||||
lastDate = currentDate;
|
||||
}
|
||||
|
||||
// Check if streak is still active (last check-in was today or yesterday)
|
||||
if (sortedDays.length > 0) {
|
||||
const lastCheckIn = new Date(sortedDays[0]);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
lastCheckIn.setHours(0, 0, 0, 0);
|
||||
|
||||
if (
|
||||
lastCheckIn.getTime() !== today.getTime() &&
|
||||
lastCheckIn.getTime() !== yesterday.getTime()
|
||||
) {
|
||||
currentStreak = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const attendanceStats: AttendanceStats = {
|
||||
totalCheckIns,
|
||||
currentStreak,
|
||||
longestStreak,
|
||||
thisWeek: thisWeekCheckIns,
|
||||
thisMonth: thisMonthCheckIns,
|
||||
recentCheckIns,
|
||||
};
|
||||
|
||||
// Calculate weekly trend (last 8 weeks)
|
||||
const weeklyTrend: WeeklyStats[] = [];
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
const weekStart = new Date(now);
|
||||
weekStart.setDate(now.getDate() - now.getDay() - i * 7);
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 7);
|
||||
|
||||
const weekCheckIns = attendanceRecords.filter((r) => {
|
||||
const checkInDate = new Date(r.checkInTime);
|
||||
return checkInDate >= weekStart && checkInDate < weekEnd;
|
||||
}).length;
|
||||
|
||||
const weekGoalsCompleted = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM fitness_goals
|
||||
WHERE user_id = ?
|
||||
AND status = 'completed'
|
||||
AND completed_date >= ?
|
||||
AND completed_date < ?`,
|
||||
)
|
||||
.get(userId, weekStart.toISOString(), weekEnd.toISOString()) as {
|
||||
count: number;
|
||||
};
|
||||
|
||||
const weekActiveGoals = db
|
||||
.prepare(
|
||||
`SELECT progress
|
||||
FROM fitness_goals
|
||||
WHERE user_id = ?
|
||||
AND status = 'active'
|
||||
AND created_at < ?`,
|
||||
)
|
||||
.all(userId, weekEnd.toISOString()) as Array<{ progress: number }>;
|
||||
|
||||
const weekAvgProgress =
|
||||
weekActiveGoals.length > 0
|
||||
? weekActiveGoals.reduce((sum, g) => sum + (g.progress || 0), 0) /
|
||||
weekActiveGoals.length
|
||||
: 0;
|
||||
|
||||
weeklyTrend.push({
|
||||
week: weekStart.toISOString().split("T")[0],
|
||||
checkIns: weekCheckIns,
|
||||
goalsCompleted: weekGoalsCompleted.count,
|
||||
avgProgress: Math.round(weekAvgProgress),
|
||||
});
|
||||
}
|
||||
|
||||
const statistics: DashboardStatistics = {
|
||||
goals: goalStats,
|
||||
attendance: attendanceStats,
|
||||
weeklyTrend,
|
||||
};
|
||||
|
||||
log.debug("Statistics calculated successfully", {
|
||||
userId,
|
||||
totalGoals: goalStats.total,
|
||||
totalCheckIns,
|
||||
});
|
||||
|
||||
return NextResponse.json({ statistics });
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch statistics", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch statistics" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -39,15 +39,17 @@ export default function RecommendationsPage() {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch users
|
||||
// Fetch users - API returns { success: true, data: { users: [...] }, meta: {...} }
|
||||
const usersRes = await fetch("/api/users");
|
||||
const usersData = await usersRes.json();
|
||||
setUsers(usersData.users || []);
|
||||
const usersResult = await usersRes.json();
|
||||
const usersArray = usersResult.data?.users || usersResult.users || [];
|
||||
setUsers(usersArray);
|
||||
|
||||
// Fetch pending recommendations
|
||||
// Fetch pending recommendations - API returns { success: true, data: { recommendations: [...] }, meta: {...} }
|
||||
const recsRes = await fetch("/api/recommendations");
|
||||
const recsData = await recsRes.json();
|
||||
const allRecs = recsData.recommendations || [];
|
||||
const recsResult = await recsRes.json();
|
||||
const allRecs =
|
||||
recsResult.data?.recommendations || recsResult.recommendations || [];
|
||||
setPendingRecommendations(
|
||||
allRecs.filter((r: Recommendation) => r.status === "pending"),
|
||||
);
|
||||
|
||||
@@ -9,12 +9,16 @@ import { toast } from "@/lib/toast";
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
|
||||
fitnessProfileId: string;
|
||||
recommendationText: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: "pending" | "completed" | "approved" | "rejected";
|
||||
activityPlan: string;
|
||||
dietPlan: string;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
generatedAt: string;
|
||||
approvedAt?: string | null;
|
||||
approvedBy?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface RecommendationsProps {
|
||||
@@ -24,10 +28,7 @@ interface RecommendationsProps {
|
||||
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: "" });
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRecommendations();
|
||||
@@ -38,8 +39,13 @@ export function Recommendations({ userId }: RecommendationsProps) {
|
||||
try {
|
||||
const response = await fetch(`/api/recommendations?userId=${userId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRecommendations(data);
|
||||
const result = await response.json();
|
||||
// API returns { success: true, data: [...], meta: {...} }
|
||||
// Extract the recommendations array from the data field
|
||||
const recsArray = Array.isArray(result.data)
|
||||
? result.data
|
||||
: result.data?.recommendations || [];
|
||||
setRecommendations(recsArray);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch recommendations", error);
|
||||
@@ -48,159 +54,179 @@ export function Recommendations({ userId }: RecommendationsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRecommendation = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleGenerateRecommendation = async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const response = await fetch("/api/recommendations", {
|
||||
const response = await fetch("/api/recommendations/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchRecommendations();
|
||||
toast.success("AI recommendation generated successfully");
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || "Failed to generate recommendation");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to generate recommendation", error);
|
||||
toast.error("Failed to generate recommendation");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveRecommendation = async (recommendationId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/recommendations/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ recommendationId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchRecommendations();
|
||||
toast.success("Recommendation approved");
|
||||
} else {
|
||||
toast.error("Failed to approve recommendation");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to approve recommendation", error);
|
||||
toast.error("Failed to approve recommendation");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectRecommendation = async (recommendationId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/recommendations", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
type: newRec.type,
|
||||
content: newRec.content,
|
||||
recommendationId,
|
||||
status: "rejected",
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNewRec({ ...newRec, content: "" });
|
||||
fetchRecommendations();
|
||||
toast.success("Recommendation added successfully");
|
||||
toast.success("Recommendation rejected");
|
||||
} else {
|
||||
toast.error("Failed to add recommendation");
|
||||
toast.error("Failed to reject recommendation");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to add recommendation", error);
|
||||
log.error("Failed to reject recommendation", error);
|
||||
toast.error("Failed to reject recommendation");
|
||||
}
|
||||
};
|
||||
|
||||
const groupedRecs = {
|
||||
ai_plan: recommendations.filter((r) => r.type === "ai_plan"),
|
||||
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" | "ai_plan",
|
||||
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" || rec.status === "approved"
|
||||
? "bg-green-50 border-green-200"
|
||||
: "bg-white border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="w-full">
|
||||
<p className="text-gray-700">{rec.recommendationText}</p>
|
||||
{rec.type === "ai_plan" && (
|
||||
<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()} -{" "}
|
||||
<span
|
||||
className={
|
||||
rec.status === "completed" || rec.status === "approved"
|
||||
? "text-green-600 font-medium"
|
||||
: "text-yellow-600"
|
||||
}
|
||||
>
|
||||
{rec.status === "completed"
|
||||
? "Completed"
|
||||
: rec.status === "approved"
|
||||
? "Approved"
|
||||
: "Pending"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{type !== "ai_plan" && (
|
||||
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
|
||||
<input
|
||||
type="hidden"
|
||||
value={type}
|
||||
onChange={() => setNewRec({ ...newRec, type: type as any })}
|
||||
/>
|
||||
{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: type as any, 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 className="flex flex-row items-center justify-between">
|
||||
<h3 className="text-xl font-bold">AI Fitness Recommendations</h3>
|
||||
<Button onClick={handleGenerateRecommendation} disabled={generating}>
|
||||
{generating ? "Generating..." : "Generate New Recommendation"}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{renderSection("Daily AI Plan", "ai_plan", groupedRecs.ai_plan)}
|
||||
{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>
|
||||
{recommendations.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
No recommendations yet. Click "Generate New Recommendation" to
|
||||
create one.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{recommendations.map((rec) => (
|
||||
<div
|
||||
key={rec.id}
|
||||
className={`p-4 rounded-lg border ${
|
||||
rec.status === "approved"
|
||||
? "bg-green-50 border-green-200"
|
||||
: rec.status === "rejected"
|
||||
? "bg-red-50 border-red-200"
|
||||
: "bg-yellow-50 border-yellow-200"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 mb-1">
|
||||
Recommendation
|
||||
</p>
|
||||
<p className="text-gray-700">{rec.recommendationText}</p>
|
||||
</div>
|
||||
|
||||
{rec.activityPlan && (
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 mb-1">
|
||||
Activity Plan
|
||||
</p>
|
||||
<p className="text-gray-700 text-sm">
|
||||
{rec.activityPlan}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rec.dietPlan && (
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 mb-1">
|
||||
Diet Plan
|
||||
</p>
|
||||
<p className="text-gray-700 text-sm">{rec.dietPlan}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="text-xs text-gray-500">
|
||||
Generated: {new Date(rec.generatedAt).toLocaleString()}
|
||||
{rec.approvedAt && (
|
||||
<>
|
||||
{" "}
|
||||
• Approved:{" "}
|
||||
{new Date(rec.approvedAt).toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-medium px-2 py-1 rounded ${
|
||||
rec.status === "approved"
|
||||
? "bg-green-600 text-white"
|
||||
: rec.status === "rejected"
|
||||
? "bg-red-600 text-white"
|
||||
: "bg-yellow-600 text-white"
|
||||
}`}
|
||||
>
|
||||
{rec.status.charAt(0).toUpperCase() +
|
||||
rec.status.slice(1)}
|
||||
</span>
|
||||
{rec.status === "pending" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApproveRecommendation(rec.id)}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleRejectRecommendation(rec.id)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user