Compare commits

...
Author SHA1 Message Date
ace ddcc9f85a8 Feature/UI: updated layout, page, cards, sidebar, stats, user management 2025-12-10 14:00:43 +01:00
ace 0b9902dc5c Feature/UI: updated buttons, cards, navigation, logo 2025-12-10 11:28:57 +01:00
dimitar 62e2255b5e fix 2025-12-01 23:17:40 +01:00
dimitar d7d270510f c 2025-12-01 21:10:04 +01:00
dimitar a5c297b911 ups 2025-12-01 21:09:35 +01:00
dimitar 0011c9d4e5 currently check in implemented 2025-12-01 20:07:48 +01:00
dimitar db0d2cf215 scan food
with mock db, real db[openfoodfacts] to be implemented
2025-12-01 19:49:10 +01:00
24 changed files with 1380 additions and 472 deletions

No files matched your search

Binary file not shown.
@@ -13,21 +13,8 @@ export async function POST(req: Request) {
// Ensure user exists in DB (sync from Clerk if needed) // Ensure user exists in DB (sync from Clerk if needed)
await ensureUserSynced(userId, db) await ensureUserSynced(userId, db)
let client = await db.getClientByUserId(userId)
if (!client) {
// Auto-create client profile if it doesn't exist
console.log('Client profile not found, creating new one for user:', userId)
client = await db.createClient({
userId,
membershipType: 'basic',
membershipStatus: 'active',
joinDate: new Date()
})
}
// Check if already checked in // Check if already checked in
const activeCheckIn = await db.getActiveCheckIn(client.id) const activeCheckIn = await db.getActiveCheckIn(userId)
if (activeCheckIn) { if (activeCheckIn) {
return new NextResponse('Already checked in', { status: 400 }) return new NextResponse('Already checked in', { status: 400 })
} }
@@ -35,7 +22,7 @@ export async function POST(req: Request) {
const body = await req.json() const body = await req.json()
const { type = 'gym', notes } = body const { type = 'gym', notes } = body
const attendance = await db.checkIn(client.id, type, notes) const attendance = await db.checkIn(userId, type, notes)
return NextResponse.json(attendance) return NextResponse.json(attendance)
} catch (error) { } catch (error) {
console.error('Check-in error:', error) console.error('Check-in error:', error)
@@ -8,13 +8,8 @@ export async function POST(req: Request) {
if (!userId) return new NextResponse('Unauthorized', { status: 401 }) if (!userId) return new NextResponse('Unauthorized', { status: 401 })
const db = await getDatabase() const db = await getDatabase()
const client = await db.getClientByUserId(userId)
if (!client) { const activeCheckIn = await db.getActiveCheckIn(userId)
return new NextResponse('Client profile not found', { status: 404 })
}
const activeCheckIn = await db.getActiveCheckIn(client.id)
if (!activeCheckIn) { if (!activeCheckIn) {
return new NextResponse('No active check-in found', { status: 404 }) return new NextResponse('No active check-in found', { status: 404 })
} }
@@ -22,20 +22,7 @@ export async function GET(req: Request) {
// Ensure user exists in DB (sync from Clerk if needed) // Ensure user exists in DB (sync from Clerk if needed)
await ensureUserSynced(userId, db) await ensureUserSynced(userId, db)
let client = await db.getClientByUserId(userId) const history = await db.getAttendanceHistory(userId)
if (!client) {
// Auto-create client profile if it doesn't exist
console.log('Client profile not found, creating new one for user:', userId)
client = await db.createClient({
userId,
membershipType: 'basic',
membershipStatus: 'active',
joinDate: new Date()
})
}
const history = await db.getAttendanceHistory(client.id)
return NextResponse.json(history) return NextResponse.json(history)
} catch (error) { } catch (error) {
console.error('History error:', error) console.error('History error:', error)
+46 -1
View File
@@ -19,7 +19,52 @@ export async function GET(request: NextRequest) {
users.map(async (user) => { users.map(async (user) => {
const { password: _, ...userWithoutPassword } = user; const { password: _, ...userWithoutPassword } = user;
const client = await db.getClientByUserId(user.id); const client = await db.getClientByUserId(user.id);
return { ...userWithoutPassword, client };
// Get active check-in status and statistics for ALL users
let isCheckedIn = false;
let checkInTime = null;
let lastCheckInTime = null;
let checkInsThisWeek = 0;
let checkInsThisMonth = 0;
// Query attendance by userId (works for all user types now)
const activeCheckIn = await db.getActiveCheckIn(user.id);
if (activeCheckIn) {
isCheckedIn = true;
checkInTime = activeCheckIn.checkInTime;
}
// Get attendance history for statistics
const attendanceHistory = await db.getAttendanceHistory(user.id);
if (attendanceHistory.length > 0) {
// Last check-in is the most recent attendance record
lastCheckInTime = attendanceHistory[0].checkInTime;
// Calculate check-ins in last 7 days
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
checkInsThisWeek = attendanceHistory.filter(
a => new Date(a.checkInTime) >= weekAgo
).length;
// Calculate check-ins in last 30 days
const monthAgo = new Date();
monthAgo.setDate(monthAgo.getDate() - 30);
checkInsThisMonth = attendanceHistory.filter(
a => new Date(a.checkInTime) >= monthAgo
).length;
}
return {
...userWithoutPassword,
client,
isCheckedIn,
checkInTime,
lastCheckInTime,
checkInsThisWeek,
checkInsThisMonth
};
}), }),
); );
+1 -1
View File
@@ -26,7 +26,7 @@ export default function RootLayout({
<ClerkProvider> <ClerkProvider>
<html lang="en"> <html lang="en">
<body className={inter.className}> <body className={inter.className}>
<div className="flex min-h-screen bg-slate-50"> <div className="flex min-h-screen bg-gradient-to-br from-white via-blue-50 to-slate-50">
<Sidebar /> <Sidebar />
<main className="flex-1 ml-64 p-8"> <main className="flex-1 ml-64 p-8">
{children} {children}
+122 -48
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Users, CreditCard, CalendarCheck, TrendingUp } from "lucide-react"; import { Users, Activity, Zap, TrendingUp, Trophy, Target } from "lucide-react";
import { StatsCard } from "@/components/ui/StatsCard"; import { StatsCard } from "@/components/ui/StatsCard";
import { UserManagement } from "@/components/users/UserManagement";
import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard"; import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard";
import { UserManagement } from "@/components/users/UserManagement";
import axios from "axios"; import axios from "axios";
interface DashboardStats { interface DashboardStats {
@@ -46,56 +46,130 @@ export default function Home() {
}; };
return ( return (
<div className="space-y-8"> <div className="min-h-screen bg-gradient-to-br from-white via-slate-50 to-blue-50">
<div> {/* Premium Header */}
<h2 className="text-3xl font-bold text-slate-900">Dashboard</h2> <div className="bg-gradient-to-r from-slate-900 via-blue-900 to-slate-900 shadow-2xl">
<p className="text-slate-500 mt-2">Welcome back, here's what's happening today.</p> <div className="max-w-7xl mx-auto px-8 py-12">
<div className="flex items-end justify-between">
<div className="flex items-end gap-6">
<div className="w-16 h-16 bg-gradient-to-br from-blue-400 to-cyan-400 rounded-3xl flex items-center justify-center shadow-lg">
<Trophy className="w-9 h-9 text-white" />
</div>
<div>
<h1 className="text-5xl font-black text-white tracking-tight">FitAI</h1>
<p className="text-blue-200 text-sm font-semibold mt-2">Premium Sports Management Platform</p>
</div>
</div>
<div className="text-right">
<p className="text-blue-100 text-sm">Welcome</p>
<p className="text-2xl font-bold text-white">Admin Control</p>
</div>
</div>
</div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> {/* Main Content with Proper Spacing */}
<StatsCard <div className="max-w-7xl mx-auto px-8 py-16">
title="Total Users"
value={loading ? "..." : stats.totalUsers} {/* Section: Performance Metrics */}
change="+12%" // Placeholder for now as we don't track historical growth yet <div className="mb-20">
trend="up" <div className="mb-10">
icon={Users} <h2 className="text-3xl font-black text-slate-900">Performance Metrics</h2>
color="blue" <p className="text-slate-600 mt-2 font-medium">Real-time insights into your platform</p>
/> </div>
<StatsCard
title="Active Clients" <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
value={loading ? "..." : stats.activeClients} <StatsCard
change="+5%" title="Total Members"
trend="up" value={loading ? "..." : stats.totalUsers}
icon={CalendarCheck} change="+12% this month"
color="green" trend="up"
/> icon={Users}
<StatsCard color="blue"
title="Revenue" />
value={loading ? "..." : formatCurrency(stats.totalRevenue)} <StatsCard
change={`${stats.revenueGrowth > 0 ? "+" : ""}${stats.revenueGrowth}%`} title="Active Athletes"
trend={stats.revenueGrowth >= 0 ? "up" : "down"} value={loading ? "..." : stats.activeClients}
icon={CreditCard} change="+8% this week"
color="purple" trend="up"
/> icon={Activity}
<StatsCard color="green"
title="Growth" />
value="24%" // Placeholder <StatsCard
change="-2%" title="Revenue"
trend="down" value={loading ? "..." : formatCurrency(stats.totalRevenue)}
icon={TrendingUp} change={`${stats.revenueGrowth > 0 ? "+" : ""}${stats.revenueGrowth}% growth`}
color="orange" trend={stats.revenueGrowth >= 0 ? "up" : "down"}
/> icon={Zap}
</div> color="amber"
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <StatsCard
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-slate-100 p-6"> title="Growth"
<h3 className="text-xl font-bold text-slate-900 mb-6">Recent Activity</h3> value="24%"
<UserManagement /> change="+3% vs last month"
trend="up"
icon={TrendingUp}
color="cyan"
/>
</div>
</div> </div>
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6"> {/* Section: Member Management */}
<h3 className="text-xl font-bold text-slate-900 mb-6">Quick Analytics</h3> <div className="mb-20">
<AnalyticsDashboard /> <div className="mb-8">
<h2 className="text-3xl font-black text-slate-900">Members Directory</h2>
<p className="text-slate-600 mt-2 font-medium">View detailed member information and analytics</p>
</div>
<div className="bg-white rounded-3xl shadow-lg border border-slate-100 p-10">
<UserManagement />
</div>
</div>
{/* Section: Key Insights */}
<div className="mb-20">
<div className="mb-8">
<h2 className="text-3xl font-black text-slate-900">Key Insights</h2>
<p className="text-slate-600 mt-2 font-medium">Quick stats overview</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="bg-white rounded-3xl shadow-lg border border-slate-100 p-8">
<div className="p-6 bg-gradient-to-br from-blue-50 via-blue-100 to-blue-50 rounded-2xl border border-blue-200 hover:shadow-md transition-shadow">
<p className="text-xs text-blue-700 uppercase font-black tracking-wider">Members</p>
<p className="text-3xl font-black text-blue-900 mt-4 truncate">{loading ? "..." : stats.totalUsers}</p>
<p className="text-sm text-blue-700 mt-2 font-semibold">Active subscriptions</p>
</div>
</div>
<div className="bg-white rounded-3xl shadow-lg border border-slate-100 p-8">
<div className="p-6 bg-gradient-to-br from-emerald-50 via-emerald-100 to-emerald-50 rounded-2xl border border-emerald-200 hover:shadow-md transition-shadow">
<p className="text-xs text-emerald-700 uppercase font-black tracking-wider">Training Now</p>
<p className="text-3xl font-black text-emerald-900 mt-4 truncate">{loading ? "..." : stats.activeClients}</p>
<p className="text-sm text-emerald-700 mt-2 font-semibold">Currently active</p>
</div>
</div>
<div className="bg-white rounded-3xl shadow-lg border border-slate-100 p-8">
<div className="p-6 bg-gradient-to-br from-amber-50 via-amber-100 to-amber-50 rounded-2xl border border-amber-200 hover:shadow-md transition-shadow">
<p className="text-xs text-amber-700 uppercase font-black tracking-wider">Revenue</p>
<p className="text-2xl font-black text-amber-900 mt-4 truncate">{loading ? "..." : formatCurrency(stats.totalRevenue)}</p>
<p className="text-sm text-amber-700 mt-2 font-semibold">This period</p>
</div>
</div>
</div>
</div>
{/* Section: Detailed Report - Full Width */}
<div className="mb-20">
<div className="mb-8">
<h2 className="text-3xl font-black text-slate-900">Detailed Report</h2>
<p className="text-slate-600 mt-2 font-medium">Analytics Dashboard</p>
</div>
<div className="bg-white rounded-3xl shadow-lg border border-slate-100 p-10">
<div className="bg-slate-50 rounded-2xl p-8">
<AnalyticsDashboard />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
+15 -14
View File
@@ -65,14 +65,15 @@ export function Sidebar() {
]; ];
return ( return (
<aside className="w-64 bg-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800"> <aside className="w-64 bg-gradient-to-b from-white to-slate-50 h-screen fixed left-0 top-0 flex flex-col border-r border-slate-200/50">
<div className="p-6 border-b border-slate-800"> <div className="p-6 border-b border-slate-200">
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent"> <h1 className="text-2xl font-black bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
FitAI Admin FitAI
</h1> </h1>
<p className="text-xs text-slate-600 font-medium mt-1">Sports Admin</p>
</div> </div>
<nav className="flex-1 p-4 space-y-2"> <nav className="flex-1 p-4 space-y-1">
{menuItems.map((item) => { {menuItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = pathname === item.href; const isActive = pathname === item.href;
@@ -81,25 +82,25 @@ export function Sidebar() {
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 group ${isActive className={`flex items-center gap-3 px-4 py-3 rounded-2xl transition-all duration-200 group font-medium ${isActive
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20" ? "bg-gradient-to-r from-blue-500 to-cyan-500 text-white shadow-md shadow-blue-500/20"
: "text-slate-400 hover:bg-slate-800 hover:text-white"}`} : "text-slate-700 hover:bg-slate-100 hover:text-blue-600"}`}
> >
<Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-white"} /> <Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-blue-600"} />
<span className="font-medium">{label}</span> <span className="text-sm">{label}</span>
</Link> </Link>
); );
})} })}
</nav> </nav>
<div className="p-4 border-t border-slate-800"> <div className="p-4 border-t border-slate-200">
<div className="flex items-center gap-3 px-4 py-3 rounded-lg bg-slate-800/50"> <div className="flex items-center gap-3 px-4 py-3 rounded-2xl bg-slate-100">
<UserButton afterSignOutUrl="/" /> <UserButton afterSignOutUrl="/" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate"> <p className="text-sm font-semibold text-slate-900 truncate">
{user?.fullName || "Admin User"} {user?.fullName || "Admin User"}
</p> </p>
<p className="text-xs text-slate-400 truncate"> <p className="text-xs text-slate-600 truncate">
{user?.primaryEmailAddress?.emailAddress} {user?.primaryEmailAddress?.emailAddress}
</p> </p>
</div> </div>
+54 -33
View File
@@ -1,5 +1,4 @@
import { LucideIcon } from "lucide-react"; import { LucideIcon } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface StatsCardProps { interface StatsCardProps {
title: string; title: string;
@@ -7,45 +6,67 @@ interface StatsCardProps {
change?: string; change?: string;
trend?: "up" | "down" | "neutral"; trend?: "up" | "down" | "neutral";
icon: LucideIcon; icon: LucideIcon;
color?: "blue" | "green" | "purple" | "orange"; color?: "blue" | "green" | "amber" | "cyan";
} }
export function StatsCard({ title, value, change, trend, icon: Icon, color = "blue" }: StatsCardProps) { export function StatsCard({ title, value, change, trend, icon: Icon, color = "blue" }: StatsCardProps) {
const colorStyles = { const colorStyles = {
blue: "bg-blue-50 text-blue-600", blue: {
green: "bg-green-50 text-green-600", bg: "from-blue-50 via-blue-100 to-blue-50",
purple: "bg-purple-50 text-purple-600", text: "text-blue-950",
orange: "bg-orange-50 text-orange-600", icon: "bg-gradient-to-br from-blue-500 to-blue-600",
border: "border-blue-200",
label: "text-blue-700"
},
green: {
bg: "from-emerald-50 via-emerald-100 to-emerald-50",
text: "text-emerald-950",
icon: "bg-gradient-to-br from-emerald-500 to-emerald-600",
border: "border-emerald-200",
label: "text-emerald-700"
},
amber: {
bg: "from-amber-50 via-amber-100 to-amber-50",
text: "text-amber-950",
icon: "bg-gradient-to-br from-amber-500 to-amber-600",
border: "border-amber-200",
label: "text-amber-700"
},
cyan: {
bg: "from-cyan-50 via-cyan-100 to-cyan-50",
text: "text-cyan-950",
icon: "bg-gradient-to-br from-cyan-500 to-cyan-600",
border: "border-cyan-200",
label: "text-cyan-700"
},
}; };
const style = colorStyles[color];
return ( return (
<Card> <div className={`bg-gradient-to-br ${style.bg} rounded-3xl border ${style.border} p-6 hover:shadow-xl transition-all duration-300 cursor-default group`}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <div className="flex items-start justify-between mb-4 gap-3">
<CardTitle className="text-sm font-medium text-muted-foreground"> <div className="flex-1 min-w-0">
{title} <p className={`text-xs ${style.label} uppercase font-black tracking-widest truncate`}>
</CardTitle> {title}
<div className={`p-2 rounded-lg ${colorStyles[color]}`}>
<Icon size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{change && (
<p className="text-xs text-muted-foreground mt-1">
<span
className={`font-medium ${trend === "up"
? "text-green-600"
: trend === "down"
? "text-red-600"
: "text-slate-600"
}`}
>
{change}
</span>{" "}
vs last month
</p> </p>
)} </div>
</CardContent> <div className={`p-3 ${style.icon} rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform flex-shrink-0`}>
</Card> <Icon size={20} className="text-white" />
</div>
</div>
<div className={`text-4xl font-black ${style.text} mb-3 truncate`}>
{value}
</div>
{change && (
<div className="flex items-center gap-1 truncate">
<span className={`text-xs font-black uppercase tracking-wide ${style.label} truncate`}>
{trend === "up" ? "↑" : trend === "down" ? "↓" : "→"} {change}
</span>
</div>
)}
</div>
); );
} }
+19 -52
View File
@@ -1,56 +1,23 @@
import * as React from "react"; import React from 'react'
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary'
const buttonVariants = cva( children: React.ReactNode
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( export function Button({ variant = 'primary', children, className = '', ...props }: ButtonProps) {
({ className, variant, size, asChild = false, ...props }, ref) => { const baseClasses = 'px-4 py-2 rounded-md font-medium transition-colors'
const Comp = asChild ? Slot : "button"; const variantClasses = {
return ( primary: 'bg-blue-600 text-white hover:bg-blue-700',
<Comp secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
className={cn(buttonVariants({ variant, size, className }))} }
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants }; return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
{...props}
>
{children}
</button>
)
}
+33 -71
View File
@@ -1,76 +1,38 @@
import * as React from "react" import React from 'react'
import { cn } from "@/lib/utils" interface CardProps {
children: React.ReactNode
className?: string
}
const Card = React.forwardRef< export function Card({ children, className = '' }: CardProps) {
HTMLDivElement, return (
React.HTMLAttributes<HTMLDivElement> <div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
>(({ className, ...props }, ref) => ( {children}
<div </div>
ref={ref} )
className={cn( }
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef< export function CardHeader({ children, className = '' }: CardProps) {
HTMLDivElement, return (
React.HTMLAttributes<HTMLDivElement> <div className={`mb-4 ${className}`}>
>(({ className, ...props }, ref) => ( {children}
<div </div>
ref={ref} )
className={cn("flex flex-col space-y-1.5 p-6", className)} }
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef< export function CardContent({ children, className = '' }: CardProps) {
HTMLParagraphElement, return (
React.HTMLAttributes<HTMLHeadingElement> <div className={className}>
>(({ className, ...props }, ref) => ( {children}
<h3 </div>
ref={ref} )
className={cn("font-semibold leading-none tracking-tight", className)} }
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef< export function CardTitle({ children, className = '' }: CardProps) {
HTMLParagraphElement, return (
React.HTMLAttributes<HTMLParagraphElement> <div className={`text-sm font-medium ${className}`}>
>(({ className, ...props }, ref) => ( {children}
<p </div>
ref={ref} )
className={cn("text-sm text-muted-foreground", className)} }
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -9,6 +9,20 @@ import { formatDate } from "@/lib/utils";
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
function getTimeAgo(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
interface User { interface User {
id: string; id: string;
email: string; email: string;
@@ -17,6 +31,8 @@ interface User {
role: string; role: string;
phone?: string; phone?: string;
createdAt: Date; createdAt: Date;
isCheckedIn?: boolean;
checkInTime?: Date;
client?: { client?: {
id: string; id: string;
membershipType: string; membershipType: string;
@@ -152,6 +168,27 @@ export function UserGrid({
}, },
minWidth: 120, minWidth: 120,
}, },
{
headerName: "Currently Checked In",
valueGetter: (params) => params.data?.isCheckedIn,
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
if (!params.data?.isCheckedIn) {
return <span className="text-gray-400">—</span>;
}
const checkInTime = params.data.checkInTime ? new Date(params.data.checkInTime) : null;
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : '';
return (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
✓ Checked In {timeAgo && `(${timeAgo})`}
</span>
);
},
minWidth: 180,
},
{ {
headerName: "Join Date", headerName: "Join Date",
valueGetter: (params) => valueGetter: (params) =>
+278 -160
View File
@@ -2,7 +2,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 {
@@ -13,6 +13,11 @@ interface User {
role: string; role: string;
phone?: string; phone?: string;
createdAt: Date; createdAt: Date;
isCheckedIn?: boolean;
checkInTime?: Date;
lastCheckInTime?: Date;
checkInsThisWeek?: number;
checkInsThisMonth?: number;
client?: { client?: {
id: string; id: string;
membershipType: string; membershipType: string;
@@ -29,6 +34,7 @@ export function UserManagement() {
const [selectedUser, setSelectedUser] = useState<User | null>(null); const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [activeTab, setActiveTab] = useState<"users" | "details">("users");
const [editForm, setEditForm] = useState<{ const [editForm, setEditForm] = useState<{
firstName: string; firstName: string;
lastName: string; lastName: string;
@@ -200,104 +206,228 @@ export function UserManagement() {
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<div className="flex justify-between items-center"> {/* Header with Count */}
<h2 className="text-2xl font-bold">User Management</h2> <div className="flex items-center justify-between">
<div className="flex gap-2"> <h3 className="text-lg font-black text-slate-900">
<Button {users.length} {users.length === 1 ? 'user' : 'users'}
variant={filter === "all" ? "primary" : "secondary"} </h3>
onClick={() => setFilter("all")} {selectedUser && (
> <p className="text-sm text-blue-600 font-semibold">
All Users Selected: {selectedUser.firstName} {selectedUser.lastName}
</Button> </p>
<Button )}
variant="secondary" </div>
onClick={() => selectedUser && handleEditUser(selectedUser)}
disabled={!selectedUser} {/* Compact Controls - Always Visible but Organized */}
> <div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-4 bg-gradient-to-r from-slate-50 to-blue-50 rounded-lg border border-slate-200">
Edit User {/* Filter Row */}
</Button> <div>
<Button <p className="text-xs font-black uppercase tracking-wider text-slate-600 mb-2">Roles</p>
variant="secondary" <div className="flex flex-wrap gap-2">
onClick={() => { <button
setEditForm({ onClick={() => setFilter("all")}
firstName: "", className={`px-2 py-1 text-xs font-semibold rounded-lg transition-all ${
lastName: "", filter === "all"
email: "", ? "bg-blue-500 text-white shadow-md"
role: "client", : "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
phone: "", }`}
}); >
setSelectedUser(null); All
setIsEditing(true); </button>
}} <button
> onClick={() => setFilter("client")}
Invite User className={`px-2 py-1 text-xs font-semibold rounded-lg transition-all ${
</Button> filter === "client"
<Button ? "bg-blue-500 text-white shadow-md"
variant="secondary" : "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
onClick={() => selectedUser && handleDeleteUser(selectedUser)} }`}
disabled={!selectedUser} >
> Clients
Delete User </button>
</Button> <button
<Button onClick={() => setFilter("trainer")}
variant={filter === "client" ? "primary" : "secondary"} className={`px-2 py-1 text-xs font-semibold rounded-lg transition-all ${
onClick={() => setFilter("client")} filter === "trainer"
> ? "bg-blue-500 text-white shadow-md"
Clients : "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
</Button> }`}
<Button >
variant={filter === "trainer" ? "primary" : "secondary"} Trainers
onClick={() => setFilter("trainer")} </button>
> <button
Trainers onClick={() => setFilter("admin")}
</Button> className={`px-2 py-1 text-xs font-semibold rounded-lg transition-all ${
<Button filter === "admin"
variant={filter === "admin" ? "primary" : "secondary"} ? "bg-blue-500 text-white shadow-md"
onClick={() => setFilter("admin")} : "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
> }`}
Admins >
</Button> Admins
<Button </button>
variant={filter === "superAdmin" ? "primary" : "secondary"} <button
onClick={() => setFilter("superAdmin")} onClick={() => setFilter("superAdmin")}
> className={`px-2 py-1 text-xs font-semibold rounded-lg transition-all ${
Super Admins filter === "superAdmin"
</Button> ? "bg-blue-500 text-white shadow-md"
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
}`}
>
Super Admins
</button>
</div>
</div>
{/* Action Buttons Row */}
<div>
<p className="text-xs font-black uppercase tracking-wider text-slate-600 mb-2">Quick Actions</p>
<div className="flex flex-wrap gap-2">
<button
onClick={() => {
setEditForm({
firstName: "",
lastName: "",
email: "",
role: "client",
phone: "",
});
setSelectedUser(null);
setIsEditing(true);
}}
className="px-2 py-1 text-xs font-semibold rounded-lg bg-blue-500 text-white hover:bg-blue-600"
>
+ Invite
</button>
<button
onClick={() => selectedUser && handleEditUser(selectedUser)}
disabled={!selectedUser}
className="px-2 py-1 text-xs font-semibold rounded-lg bg-white text-slate-700 hover:bg-slate-100 border border-slate-200 disabled:opacity-50"
>
Edit
</button>
<button
onClick={() => selectedUser && handleDeleteUser(selectedUser)}
disabled={!selectedUser}
className="px-2 py-1 text-xs font-semibold rounded-lg bg-white text-slate-700 hover:bg-slate-100 border border-slate-200 disabled:opacity-50"
>
Delete
</button>
<button onClick={handleRefresh} className="px-2 py-1 text-xs font-semibold rounded-lg bg-white text-slate-700 hover:bg-slate-100 border border-slate-200">
Refresh
</button>
<button onClick={handleExport} className="px-2 py-1 text-xs font-semibold rounded-lg bg-white text-slate-700 hover:bg-slate-100 border border-slate-200">
Export
</button>
</div>
</div> </div>
</div> </div>
<div className="flex justify-between items-center"> {/* Tab Navigation */}
<div className="text-sm text-gray-600"> <div className="flex gap-2 border-b border-slate-200">
Showing {users.length} users <button
{selectedUser && ( onClick={() => setActiveTab("users")}
<span className="ml-4 text-blue-600"> className={`px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
Selected: {selectedUser.firstName} {selectedUser.lastName} activeTab === "users"
</span> ? "border-blue-500 text-blue-600"
)} : "border-transparent text-slate-600 hover:text-slate-900"
</div> }`}
<div className="flex gap-2"> >
<Button variant="secondary" onClick={handleRefresh}> User List ({users.length})
Refresh </button>
</Button> <button
<Button variant="secondary" onClick={handleExport}> onClick={() => setActiveTab("details")}
Export CSV disabled={!selectedUser}
</Button> className={`px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
</div> activeTab === "details"
? "border-blue-500 text-blue-600"
: "border-transparent text-slate-600 hover:text-slate-900"
} ${!selectedUser ? "opacity-50 cursor-not-allowed" : ""}`}
>
User Details
</button>
</div> </div>
<Card> {/* Tab Content */}
<CardContent className="p-0"> {activeTab === "users" && (
<UserGrid <Card className="border border-slate-200">
users={users} <CardContent className="p-0">
onUserSelect={(user) => handleUserSelect(user)} <UserGrid
onEditUser={handleEditUser} users={users}
onDeleteUser={handleDeleteUser} onUserSelect={(user) => {
onBulkDelete={handleBulkDelete} handleUserSelect(user);
loading={loading} setActiveTab("details");
/> }}
</CardContent> onEditUser={handleEditUser}
</Card> onDeleteUser={handleDeleteUser}
onBulkDelete={handleBulkDelete}
loading={loading}
/>
</CardContent>
</Card>
)}
{activeTab === "details" && selectedUser && (
<div className="p-6 bg-white rounded-xl border border-slate-200">
<div className="mb-6">
<h3 className="text-xl font-black text-slate-900">
{selectedUser.firstName} {selectedUser.lastName}
</h3>
<p className="text-sm text-slate-600 mt-1">{selectedUser.email}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="space-y-2">
<p className="text-xs font-black uppercase tracking-wider text-slate-600">Role</p>
<p className="text-base font-semibold text-slate-900">{selectedUser.role}</p>
</div>
<div className="space-y-2">
<p className="text-xs font-black uppercase tracking-wider text-slate-600">Joined</p>
<p className="text-base font-semibold text-slate-900">{new Date(selectedUser.createdAt).toLocaleDateString()}</p>
</div>
{selectedUser.client && (
<>
<div className="space-y-2">
<p className="text-xs font-black uppercase tracking-wider text-slate-600">Membership</p>
<p className="text-base font-semibold text-slate-900">{selectedUser.client.membershipType}</p>
</div>
<div className="space-y-2">
<p className="text-xs font-black uppercase tracking-wider text-slate-600">Status</p>
<p className="text-base font-semibold text-slate-900">{selectedUser.client.membershipStatus}</p>
</div>
</>
)}
<div className="space-y-2">
<p className="text-xs font-black uppercase tracking-wider text-slate-600">Check-ins This Month</p>
<p className="text-2xl font-black text-blue-600">{selectedUser.checkInsThisMonth || 0}</p>
</div>
</div>
<div className="flex gap-3 mt-8 pt-6 border-t border-slate-200">
<button
onClick={() => selectedUser && handleEditUser(selectedUser)}
className="px-5 py-2 bg-blue-50 text-blue-600 rounded-lg font-semibold text-sm hover:bg-blue-100 transition-all"
>
Edit User
</button>
<button
onClick={() => selectedUser && handleDeleteUser(selectedUser)}
className="px-5 py-2 bg-red-50 text-red-600 rounded-lg font-semibold text-sm hover:bg-red-100 transition-all"
>
Delete User
</button>
<a
href={`/users/${selectedUser.id}`}
className="px-5 py-2 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-lg font-semibold text-sm hover:shadow-lg transition-all"
>
Full Profile
</a>
</div>
</div>
)}
{isEditing && editForm && ( {isEditing && editForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
@@ -433,76 +563,64 @@ export function UserManagement() {
)} )}
{selectedUser && ( {selectedUser && (
<Card> <div className="mt-8 pt-8 border-t border-slate-200">
<CardHeader className="flex flex-row items-center justify-between"> <div className="mb-6">
<h3 className="text-lg font-semibold">User Details</h3> <h3 className="text-lg font-black text-slate-900">
{selectedUser.firstName} {selectedUser.lastName}
</h3>
<p className="text-sm text-slate-600 mt-1">{selectedUser.email}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="space-y-3">
<p className="text-xs font-black uppercase tracking-wider text-slate-600">Role</p>
<p className="text-base font-semibold text-slate-900">{selectedUser.role}</p>
<p className="text-xs font-black uppercase tracking-wider text-slate-600 mt-4">Joined</p>
<p className="text-base font-semibold text-slate-900">{new Date(selectedUser.createdAt).toLocaleDateString()}</p>
</div>
{selectedUser.client && (
<div className="space-y-3">
<p className="text-xs font-black uppercase tracking-wider text-slate-600">Membership</p>
<p className="text-base font-semibold text-slate-900">{selectedUser.client.membershipType}</p>
<p className="text-xs font-black uppercase tracking-wider text-slate-600 mt-4">Status</p>
<p className="text-base font-semibold text-slate-900">{selectedUser.client.membershipStatus}</p>
</div>
)}
<div className="space-y-3">
<p className="text-xs font-black uppercase tracking-wider text-slate-600">Check-ins This Month</p>
<p className="text-2xl font-black text-blue-600">{selectedUser.checkInsThisMonth || 0}</p>
<p className="text-xs font-black uppercase tracking-wider text-slate-600 mt-4">Last Check-in</p>
<p className="text-sm text-slate-900">
{selectedUser.lastCheckInTime
? new Date(selectedUser.lastCheckInTime).toLocaleDateString()
: "Never"}
</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => selectedUser && handleEditUser(selectedUser)}
className="px-5 py-2 bg-blue-50 text-blue-600 rounded-lg font-semibold text-sm hover:bg-blue-100 transition-all"
>
Edit
</button>
<button
onClick={() => selectedUser && handleDeleteUser(selectedUser)}
className="px-5 py-2 bg-red-50 text-red-600 rounded-lg font-semibold text-sm hover:bg-red-100 transition-all"
>
Delete
</button>
<a <a
href={`/users/${selectedUser.id}`} href={`/users/${selectedUser.id}`}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm font-medium" className="px-5 py-2 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-lg font-semibold text-sm hover:shadow-lg transition-all"
> >
View Full Profile & Recommendations Full Profile
</a> </a>
</CardHeader> </div>
<CardContent> </div>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="font-medium mb-2">Basic Information</h4>
<div className="space-y-1 text-sm">
<p>
<span className="font-medium">Name:</span>{" "}
{selectedUser.firstName} {selectedUser.lastName}
</p>
<p>
<span className="font-medium">Email:</span>{" "}
{selectedUser.email}
</p>
<p>
<span className="font-medium">Phone:</span>{" "}
{selectedUser.phone || "N/A"}
</p>
<p>
<span className="font-medium">Role:</span>{" "}
{selectedUser.role}
</p>
<p>
<span className="font-medium">Joined:</span>{" "}
{new Date(selectedUser.createdAt).toLocaleDateString()}
</p>
</div>
</div>
{selectedUser.client && (
<div>
<h4 className="font-medium mb-2">Client Information</h4>
<div className="space-y-1 text-sm">
<p>
<span className="font-medium">Membership:</span>{" "}
{selectedUser.client.membershipType}
</p>
<p>
<span className="font-medium">Status:</span>{" "}
{selectedUser.client.membershipStatus}
</p>
<p>
<span className="font-medium">Member Since:</span>{" "}
{new Date(
selectedUser.client.joinDate,
).toLocaleDateString()}
</p>
<p>
<span className="font-medium">Last Visit:</span>{" "}
{selectedUser.client.lastVisit
? new Date(
selectedUser.client.lastVisit,
).toLocaleDateString()
: "Never"}
</p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)} )}
</div> </div>
); );
+78 -33
View File
@@ -106,19 +106,61 @@ export class SQLiteDatabase implements IDatabase {
CREATE INDEX IF NOT EXISTS idx_fitness_profiles_userId ON fitness_profiles(userId); CREATE INDEX IF NOT EXISTS idx_fitness_profiles_userId ON fitness_profiles(userId);
`) `)
// Attendance table // Attendance table migration: change from clientId to userId
this.db.exec(` // Check if old table exists and migrate
CREATE TABLE IF NOT EXISTS attendance ( const tableInfo = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'").get() as any;
id TEXT PRIMARY KEY,
clientId TEXT NOT NULL, if (tableInfo) {
checkInTime DATETIME NOT NULL, // Check if table has clientId column (old schema)
checkOutTime DATETIME, const columns = this.db.prepare("PRAGMA table_info(attendance)").all() as any[];
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')), const hasClientId = columns.some((col: any) => col.name === 'clientId');
notes TEXT,
createdAt DATETIME NOT NULL, if (hasClientId) {
FOREIGN KEY (clientId) REFERENCES clients (id) ON DELETE CASCADE console.log('Migrating attendance table from clientId to userId...');
)
`) // Create new table with userId
this.db.exec(`
CREATE TABLE IF NOT EXISTS attendance_new (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
checkInTime DATETIME NOT NULL,
checkOutTime DATETIME,
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
notes TEXT,
createdAt DATETIME NOT NULL,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
)
`);
// Migrate data: map clientId to userId via clients table
this.db.exec(`
INSERT INTO attendance_new (id, userId, checkInTime, checkOutTime, type, notes, createdAt)
SELECT a.id, c.userId, a.checkInTime, a.checkOutTime, a.type, a.notes, a.createdAt
FROM attendance a
JOIN clients c ON a.clientId = c.id
`);
// Drop old table and rename new one
this.db.exec(`DROP TABLE attendance`);
this.db.exec(`ALTER TABLE attendance_new RENAME TO attendance`);
console.log('Attendance table migration completed');
}
} else {
// Create new attendance table with userId
this.db.exec(`
CREATE TABLE IF NOT EXISTS attendance (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
checkInTime DATETIME NOT NULL,
checkOutTime DATETIME,
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
notes TEXT,
createdAt DATETIME NOT NULL,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
)
`);
}
// Recommendations table // Recommendations table
// Removed DROP TABLE to persist data. Schema is now stable. // Removed DROP TABLE to persist data. Schema is now stable.
@@ -140,16 +182,16 @@ export class SQLiteDatabase implements IDatabase {
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) FOREIGN KEY (fitnessProfileId) REFERENCES fitness_profiles (userId)
) )
`) `);
this.db.exec(` this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_recommendations_userId ON recommendations(userId); 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_userId ON attendance(userId);
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime); CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
`) `);
} }
// User operations // User operations
@@ -389,7 +431,7 @@ export class SQLiteDatabase implements IDatabase {
} }
// Attendance operations // Attendance operations
async checkIn(clientId: string, type: 'gym' | 'class' | 'personal_training', notes?: string): Promise<Attendance> { async checkIn(userId: string, type: 'gym' | 'class' | 'personal_training', notes?: string): Promise<Attendance> {
if (!this.db) throw new Error('Database not connected') if (!this.db) throw new Error('Database not connected')
const id = Math.random().toString(36).substr(2, 9) const id = Math.random().toString(36).substr(2, 9)
@@ -397,7 +439,7 @@ export class SQLiteDatabase implements IDatabase {
const attendance: Attendance = { const attendance: Attendance = {
id, id,
clientId, userId,
checkInTime: now, checkInTime: now,
type, type,
notes, notes,
@@ -405,20 +447,23 @@ 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, userId, checkInTime, type, notes, createdAt)
VALUES(?, ?, ?, ?, ?, ?)` VALUES(?, ?, ?, ?, ?, ?)`
) )
stmt.run( stmt.run(
attendance.id, attendance.clientId, attendance.checkInTime.toISOString(), attendance.id, attendance.userId, attendance.checkInTime.toISOString(),
attendance.type, attendance.notes, attendance.createdAt.toISOString() attendance.type, attendance.notes, attendance.createdAt.toISOString()
) )
// Update client last visit // Update client last visit if user is a client
this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run( const client = await this.getClientByUserId(userId);
now.toISOString(), if (client) {
clientId this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run(
) now.toISOString(),
client.id
);
}
return attendance return attendance
} }
@@ -440,10 +485,10 @@ export class SQLiteDatabase implements IDatabase {
return row ? this.mapRowToAttendance(row) : null return row ? this.mapRowToAttendance(row) : null
} }
async getAttendanceHistory(clientId: string): Promise<Attendance[]> { async getAttendanceHistory(userId: string): Promise<Attendance[]> {
if (!this.db) throw new Error('Database not connected') if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? ORDER BY checkInTime DESC') const stmt = this.db.prepare('SELECT * FROM attendance WHERE userId = ? ORDER BY checkInTime DESC')
const rows = stmt.all(clientId) const rows = stmt.all(userId)
return rows.map(row => this.mapRowToAttendance(row)) return rows.map(row => this.mapRowToAttendance(row))
} }
@@ -454,10 +499,10 @@ export class SQLiteDatabase implements IDatabase {
return rows.map(row => this.mapRowToAttendance(row)) return rows.map(row => this.mapRowToAttendance(row))
} }
async getActiveCheckIn(clientId: string): Promise<Attendance | null> { async getActiveCheckIn(userId: string): Promise<Attendance | null> {
if (!this.db) throw new Error('Database not connected') if (!this.db) throw new Error('Database not connected')
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? AND checkOutTime IS NULL ORDER BY checkInTime DESC LIMIT 1') const stmt = this.db.prepare('SELECT * FROM attendance WHERE userId = ? AND checkOutTime IS NULL ORDER BY checkInTime DESC LIMIT 1')
const row = stmt.get(clientId) const row = stmt.get(userId)
return row ? this.mapRowToAttendance(row) : null return row ? this.mapRowToAttendance(row) : null
} }
@@ -509,7 +554,7 @@ export class SQLiteDatabase implements IDatabase {
private mapRowToAttendance(row: any): Attendance { private mapRowToAttendance(row: any): Attendance {
return { return {
id: row.id, id: row.id,
clientId: row.clientId, userId: row.userId,
checkInTime: new Date(row.checkInTime), checkInTime: new Date(row.checkInTime),
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined, checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
type: row.type, type: row.type,
+4 -4
View File
@@ -39,7 +39,7 @@ export interface FitnessProfile {
export interface Attendance { export interface Attendance {
id: string; id: string;
clientId: string; userId: string;
checkInTime: Date; checkInTime: Date;
checkOutTime?: Date; checkOutTime?: Date;
type: "gym" | "class" | "personal_training"; type: "gym" | "class" | "personal_training";
@@ -121,14 +121,14 @@ export interface IDatabase {
// Attendance operations // Attendance operations
checkIn( checkIn(
clientId: string, userId: string,
type: "gym" | "class" | "personal_training", type: "gym" | "class" | "personal_training",
notes?: string, notes?: string,
): Promise<Attendance>; ): Promise<Attendance>;
checkOut(attendanceId: string): Promise<Attendance | null>; checkOut(attendanceId: string): Promise<Attendance | null>;
getAttendanceHistory(clientId: string): Promise<Attendance[]>; getAttendanceHistory(userId: string): Promise<Attendance[]>;
getAllAttendance(): Promise<Attendance[]>; getAllAttendance(): Promise<Attendance[]>;
getActiveCheckIn(clientId: string): Promise<Attendance | null>; getActiveCheckIn(userId: string): Promise<Attendance | null>;
// Recommendation operations // Recommendation operations
createRecommendation( createRecommendation(
+11 -4
View File
@@ -15,21 +15,28 @@
"**/*" "**/*"
], ],
"ios": { "ios": {
"supportsTablet": true "supportsTablet": true,
"infoPlist": {
"NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information."
}
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff" "backgroundColor": "#ffffff"
} },
"permissions": [
"CAMERA"
]
}, },
"web": { "web": {
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
"expo-font" "expo-font",
"expo-barcode-scanner"
], ],
"scheme": "fitai" "scheme": "fitai"
} }
} }
+23 -1
View File
@@ -20,7 +20,8 @@
"axios": "^1.6.0", "axios": "^1.6.0",
"expo": "~54.0.23", "expo": "~54.0.23",
"expo-auth-session": "^7.0.8", "expo-auth-session": "^7.0.8",
"expo-camera": "~17.0.0", "expo-barcode-scanner": "^13.0.1",
"expo-camera": "~17.0.9",
"expo-constants": "^18.0.10", "expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7", "expo-crypto": "^15.0.7",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
@@ -7203,6 +7204,18 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-barcode-scanner": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/expo-barcode-scanner/-/expo-barcode-scanner-13.0.1.tgz",
"integrity": "sha512-xBGLT1An2gpAMIQRTLU3oHydKohX8r8F9/ait1Fk9Vgd0GraFZbP4IiT7nHMlaw4H6E7Muucf7vXpGV6u7d4HQ==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~4.7.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-camera": { "node_modules/expo-camera": {
"version": "17.0.9", "version": "17.0.9",
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.9.tgz", "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.9.tgz",
@@ -7282,6 +7295,15 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-image-loader": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.7.0.tgz",
"integrity": "sha512-cx+MxxsAMGl9AiWnQUzrkJMJH4eNOGlu7XkLGnAXSJrRoIiciGaKqzeaD326IyCTV+Z1fXvIliSgNW+DscvD8g==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-linear-gradient": { "node_modules/expo-linear-gradient": {
"version": "15.0.7", "version": "15.0.7",
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz", "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz",
+2 -1
View File
@@ -26,7 +26,8 @@
"axios": "^1.6.0", "axios": "^1.6.0",
"expo": "~54.0.23", "expo": "~54.0.23",
"expo-auth-session": "^7.0.8", "expo-auth-session": "^7.0.8",
"expo-camera": "~17.0.0", "expo-barcode-scanner": "^13.0.1",
"expo-camera": "~17.0.9",
"expo-constants": "^18.0.10", "expo-constants": "^18.0.10",
"expo-crypto": "^15.0.7", "expo-crypto": "^15.0.7",
"expo-font": "~14.0.9", "expo-font": "~14.0.9",
+14
View File
@@ -9,6 +9,7 @@ import { QuickActionGrid } from "../../components/QuickActionGrid";
import { TrackMealModal } from "../../components/TrackMealModal"; import { TrackMealModal } from "../../components/TrackMealModal";
import { AddWaterModal } from "../../components/AddWaterModal"; import { AddWaterModal } from "../../components/AddWaterModal";
import { HydrationWidget } from "../../components/HydrationWidget"; import { HydrationWidget } from "../../components/HydrationWidget";
import { ScanFoodModal } from "../../components/ScanFoodModal";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
export default function HomeScreen() { export default function HomeScreen() {
@@ -16,6 +17,7 @@ export default function HomeScreen() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [trackMealModalVisible, setTrackMealModalVisible] = useState(false); const [trackMealModalVisible, setTrackMealModalVisible] = useState(false);
const [addWaterModalVisible, setAddWaterModalVisible] = useState(false); const [addWaterModalVisible, setAddWaterModalVisible] = useState(false);
const [scanFoodModalVisible, setScanFoodModalVisible] = useState(false);
const [calories, setCalories] = useState(0); const [calories, setCalories] = useState(0);
const [waterIntake, setWaterIntake] = useState(0); const [waterIntake, setWaterIntake] = useState(0);
@@ -51,6 +53,11 @@ export default function HomeScreen() {
setWaterIntake(0); setWaterIntake(0);
}; };
const handleAddScannedFood = (scannedCalories: number) => {
setCalories(prev => prev + scannedCalories);
setScanFoodModalVisible(false);
};
const resetAllCounters = async () => { const resetAllCounters = async () => {
setCalories(0); setCalories(0);
setWaterIntake(0); setWaterIntake(0);
@@ -134,6 +141,7 @@ export default function HomeScreen() {
<QuickActionGrid <QuickActionGrid
onTrackMealPress={() => setTrackMealModalVisible(true)} onTrackMealPress={() => setTrackMealModalVisible(true)}
onAddWaterPress={() => setAddWaterModalVisible(true)} onAddWaterPress={() => setAddWaterModalVisible(true)}
onScanFoodPress={() => setScanFoodModalVisible(true)}
/> />
<TrackMealModal <TrackMealModal
@@ -150,6 +158,12 @@ export default function HomeScreen() {
onResetData={handleResetWater} onResetData={handleResetWater}
/> />
<ScanFoodModal
visible={scanFoodModalVisible}
onClose={() => setScanFoodModalVisible(false)}
onAddFood={handleAddScannedFood}
/>
{/* Recent Activity Section */} {/* Recent Activity Section */}
<View style={styles.section}> <View style={styles.section}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
@@ -39,9 +39,10 @@ function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) {
interface QuickActionGridProps { interface QuickActionGridProps {
onTrackMealPress?: () => void; onTrackMealPress?: () => void;
onAddWaterPress?: () => void; onAddWaterPress?: () => void;
onScanFoodPress?: () => void;
} }
export function QuickActionGrid({ onTrackMealPress, onAddWaterPress }: QuickActionGridProps) { export function QuickActionGrid({ onTrackMealPress, onAddWaterPress, onScanFoodPress }: QuickActionGridProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.sectionTitle}>Quick Actions</Text> <Text style={styles.sectionTitle}>Quick Actions</Text>
@@ -67,6 +68,7 @@ export function QuickActionGrid({ onTrackMealPress, onAddWaterPress }: QuickActi
icon="scan" icon="scan"
label="Scan Food" label="Scan Food"
gradient={theme.gradients.purple} gradient={theme.gradients.purple}
onPress={onScanFoodPress}
/> />
</View> </View>
</View> </View>
@@ -0,0 +1,448 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, Alert } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { theme } from '../styles/theme';
interface ScanFoodModalProps {
visible: boolean;
onClose: () => void;
onAddFood: (calories: number) => void;
}
// Mock food database
const FOOD_DATABASE: { [key: string]: { name: string; calories: number; servingSize: string } } = {
'0123456789': { name: 'Apple', calories: 95, servingSize: '1 medium' },
'9876543210': { name: 'Banana', calories: 105, servingSize: '1 medium' },
'5555555555': { name: 'Protein Bar', calories: 200, servingSize: '1 bar' },
'1111111111': { name: 'Greek Yogurt', calories: 150, servingSize: '1 cup' },
};
export function ScanFoodModal({ visible, onClose, onAddFood }: ScanFoodModalProps) {
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
const [foodData, setFoodData] = useState<{ name: string; calories: number; servingSize: string } | null>(null);
const [servings, setServings] = useState('1');
useEffect(() => {
if (visible) {
setScanned(false);
setFoodData(null);
setServings('1');
}
}, [visible]);
const handleBarCodeScanned = ({ data }: { data: string }) => {
if (scanned) return;
setScanned(true);
// Look up food in database
const food = FOOD_DATABASE[data];
if (food) {
setFoodData(food);
} else {
// Mock data for unknown barcodes
setFoodData({
name: 'Unknown Food',
calories: 150,
servingSize: '1 serving'
});
}
};
const handleConfirm = () => {
if (foodData) {
const totalCalories = foodData.calories * parseFloat(servings || '1');
onAddFood(totalCalories);
onClose();
}
};
const handleRescan = () => {
setScanned(false);
setFoodData(null);
setServings('1');
};
if (!permission) {
return null;
}
if (!permission.granted) {
return (
<Modal visible={visible} transparent animationType="slide">
<View style={styles.permissionContainer}>
<View style={styles.permissionContent}>
<Ionicons name="camera-outline" size={64} color={theme.colors.primary} />
<Text style={styles.permissionTitle}>Camera Permission Required</Text>
<Text style={styles.permissionText}>
We need access to your camera to scan food barcodes.
</Text>
<TouchableOpacity onPress={requestPermission} style={styles.permissionButtonContainer}>
<LinearGradient
colors={theme.gradients.primary}
style={styles.permissionButton}
>
<Text style={styles.permissionButtonText}>Grant Permission</Text>
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity onPress={onClose} style={styles.cancelButton}>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
return (
<Modal visible={visible} animationType="slide">
<View style={styles.container}>
{!foodData ? (
<>
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={28} color="#fff" />
</TouchableOpacity>
<Text style={styles.title}>Scan Food Barcode</Text>
<View style={{ width: 28 }} />
</View>
<CameraView
style={styles.camera}
facing="back"
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e', 'code128', 'code39'],
}}
>
<View style={styles.scanOverlay}>
<View style={styles.scanFrame} />
<Text style={styles.scanText}>Position barcode within frame</Text>
</View>
</CameraView>
</>
) : (
<View style={styles.resultContainer}>
<View style={styles.resultHeader}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<Ionicons name="close" size={28} color={theme.colors.gray900} />
</TouchableOpacity>
<Text style={styles.resultTitle}>Food Details</Text>
<View style={{ width: 28 }} />
</View>
<View style={styles.foodCard}>
<View style={styles.foodIconContainer}>
<LinearGradient
colors={theme.gradients.success}
style={styles.foodIcon}
>
<Ionicons name="restaurant" size={32} color="#fff" />
</LinearGradient>
</View>
<Text style={styles.foodName}>{foodData.name}</Text>
<Text style={styles.servingSize}>{foodData.servingSize}</Text>
<View style={styles.caloriesBadge}>
<Text style={styles.caloriesValue}>{foodData.calories}</Text>
<Text style={styles.caloriesLabel}>kcal per serving</Text>
</View>
<View style={styles.servingsContainer}>
<Text style={styles.label}>Number of Servings</Text>
<View style={styles.servingsInput}>
<TouchableOpacity
onPress={() => setServings(String(Math.max(0.5, parseFloat(servings || '1') - 0.5)))}
style={styles.servingsButton}
>
<Ionicons name="remove" size={20} color={theme.colors.primary} />
</TouchableOpacity>
<TextInput
style={styles.servingsValue}
value={servings}
onChangeText={setServings}
keyboardType="decimal-pad"
/>
<TouchableOpacity
onPress={() => setServings(String(parseFloat(servings || '1') + 0.5))}
style={styles.servingsButton}
>
<Ionicons name="add" size={20} color={theme.colors.primary} />
</TouchableOpacity>
</View>
</View>
<View style={styles.totalCalories}>
<Text style={styles.totalLabel}>Total Calories</Text>
<Text style={styles.totalValue}>
{Math.round(foodData.calories * parseFloat(servings || '1'))} kcal
</Text>
</View>
</View>
<View style={styles.buttonRow}>
<TouchableOpacity onPress={handleRescan} style={styles.rescanButton}>
<Text style={styles.rescanButtonText}>Scan Again</Text>
</TouchableOpacity>
<TouchableOpacity onPress={handleConfirm} style={styles.confirmButtonContainer}>
<LinearGradient
colors={theme.gradients.success}
style={styles.confirmButton}
>
<Text style={styles.confirmButtonText}>Add to Diary</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
)}
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
},
closeButton: {
padding: 4,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#fff',
},
camera: {
flex: 1,
},
scanOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
scanFrame: {
width: 250,
height: 250,
borderWidth: 2,
borderColor: '#fff',
borderRadius: 20,
backgroundColor: 'transparent',
},
scanText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
marginTop: 24,
textAlign: 'center',
},
resultContainer: {
flex: 1,
backgroundColor: theme.colors.background,
},
resultHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
backgroundColor: '#fff',
},
resultTitle: {
fontSize: 18,
fontWeight: '700',
color: theme.colors.gray900,
},
foodCard: {
margin: 20,
padding: 24,
backgroundColor: '#fff',
borderRadius: 24,
alignItems: 'center',
...theme.shadows.medium,
},
foodIconContainer: {
marginBottom: 16,
},
foodIcon: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center',
},
foodName: {
fontSize: 24,
fontWeight: '700',
color: theme.colors.gray900,
marginBottom: 8,
textAlign: 'center',
},
servingSize: {
fontSize: 16,
color: theme.colors.gray600,
marginBottom: 24,
},
caloriesBadge: {
backgroundColor: theme.colors.gray50,
paddingHorizontal: 24,
paddingVertical: 16,
borderRadius: 16,
alignItems: 'center',
marginBottom: 32,
},
caloriesValue: {
fontSize: 32,
fontWeight: '700',
color: theme.colors.primary,
},
caloriesLabel: {
fontSize: 14,
color: theme.colors.gray600,
marginTop: 4,
},
servingsContainer: {
width: '100%',
marginBottom: 24,
},
label: {
fontSize: 16,
fontWeight: '600',
color: theme.colors.gray700,
marginBottom: 12,
},
servingsInput: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
},
servingsButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: theme.colors.gray100,
justifyContent: 'center',
alignItems: 'center',
},
servingsValue: {
fontSize: 24,
fontWeight: '700',
color: theme.colors.gray900,
textAlign: 'center',
minWidth: 60,
},
totalCalories: {
width: '100%',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: 24,
borderTopWidth: 1,
borderTopColor: theme.colors.gray200,
},
totalLabel: {
fontSize: 16,
fontWeight: '600',
color: theme.colors.gray700,
},
totalValue: {
fontSize: 20,
fontWeight: '700',
color: theme.colors.gray900,
},
buttonRow: {
flexDirection: 'row',
padding: 20,
gap: 12,
},
rescanButton: {
flex: 1,
paddingVertical: 16,
borderRadius: 20,
backgroundColor: theme.colors.gray100,
alignItems: 'center',
},
rescanButtonText: {
fontSize: 16,
fontWeight: '700',
color: theme.colors.gray700,
},
confirmButtonContainer: {
flex: 1,
},
confirmButton: {
paddingVertical: 16,
borderRadius: 20,
alignItems: 'center',
},
confirmButtonText: {
fontSize: 16,
fontWeight: '700',
color: '#fff',
},
permissionContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
permissionContent: {
backgroundColor: '#fff',
borderRadius: 24,
padding: 32,
margin: 20,
alignItems: 'center',
...theme.shadows.strong,
},
permissionTitle: {
fontSize: 20,
fontWeight: '700',
color: theme.colors.gray900,
marginTop: 16,
marginBottom: 8,
},
permissionText: {
fontSize: 16,
color: theme.colors.gray600,
textAlign: 'center',
marginBottom: 24,
},
permissionButtonContainer: {
width: '100%',
marginBottom: 12,
},
permissionButton: {
paddingVertical: 16,
borderRadius: 20,
alignItems: 'center',
},
permissionButtonText: {
fontSize: 16,
fontWeight: '700',
color: '#fff',
},
cancelButton: {
paddingVertical: 12,
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: theme.colors.gray600,
},
});
+1 -1
View File
@@ -1,5 +1,5 @@
export const API_BASE_URL = __DEV__ export const API_BASE_URL = __DEV__
? 'https://694d46f62d87.ngrok-free.app' ? 'https://8679109544e4.ngrok-free.app'
: 'https://your-production-url.com' : 'https://your-production-url.com'
export const API_ENDPOINTS = { export const API_ENDPOINTS = {
+171 -12
View File
@@ -1,15 +1,174 @@
## fitai # FitAI
# description Integrated AI solution for fitness houses and their clients with Clerk authentication.
- fitai is integrated ai solution for fitness houses and their clients, ## Project Structure
its allow to easy menagment of clients, tracking of payments, usage of resourcess,
attendance, habits etc.
these will be phase one:
solution is composed of a admin app, where we are doing managment tasks, we visualize and
expose importatnt data to menagment and trainers, and a expo/reactnative mobile app for users.
via app we will be tracking attendance and payments, we will be sending notification etc.
# phase 2 ```
we will be tracking user inputs via manual input and devices, backend will analyze data and propose fitai/
excercises etc. ├── apps/
│ ├── admin/ # Next.js admin dashboard
│ └── mobile/ # React Native mobile app (Expo)
├── packages/
│ └── shared/ # Shared types and utilities
└── AGENTS.md # Development guidelines
```
## Getting Started
### Prerequisites
- Node.js >= 18.0.0
- npm >= 9.0.0
- Clerk account (sign up at https://clerk.com)
### Installation
```bash
# Install root dependencies
npm install
# Install admin dependencies
cd apps/admin && npm install
# Install mobile dependencies
cd apps/mobile && npm install --legacy-peer-deps
```
### Authentication Setup
FitAI uses Clerk for authentication. Follow these steps:
1. **Create a Clerk account** at https://dashboard.clerk.com
2. **Create a new application** in the Clerk dashboard
3. **Copy your API keys** (Publishable Key and Secret Key)
4. **Configure environment variables**:
**Admin App** (`apps/admin/.env.local`):
```env
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_key_here
CLERK_SECRET_KEY=sk_test_your_key_here
```
**Mobile App** (`apps/mobile/.env`):
```env
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_key_here
```
📖 **See [CLERK_SETUP.md](./CLERK_SETUP.md) for detailed setup instructions**
### Development
**Important**: Set up environment variables before running the apps!
```bash
# Admin dashboard (http://localhost:3000)
cd apps/admin && npm run dev
# Mobile app (http://localhost:8081) - Requires Expo SDK 54
cd apps/mobile && npm start
```
**First-time setup checklist**:
- [ ] Create Clerk account and application
- [ ] Add API keys to `.env.local` (admin) and `.env` (mobile)
- [ ] Verify both apps start without errors
- [ ] Test sign-up and sign-in flows
### Mobile App Setup
- **Expo SDK**: 50 (stable, compatible with Expo Go)
- **Assets**: Placeholder icons and splash screen included
- **Navigation**: Expo Router with tab-based layout
- **Authentication**: Secure storage with expo-secure-store
- **Babel**: babel-preset-expo for proper transpilation
### Known Compatibility Notes
- Use Expo Go with SDK 50 for mobile testing
- For SDK 54, upgrade all dependencies to latest versions
- Current setup prioritizes stability over latest features
### Build & Test
```bash
# Build all apps
npm run build
# Run tests
npm test
# Lint code
npm run lint
# Type checking
npm run typecheck
```
## Features
### Authentication (Clerk)
- 🔐 Secure email/password authentication
- ✉️ Email verification
- 🔄 Session management
- 🎨 Customizable UI components
- 📱 Multi-platform support (Web + Mobile)
- 🛡️ Built-in security features
### Admin Dashboard
- 👥 User management (CRUD operations)
- 📊 Analytics dashboard with charts
- 🎯 Role-based access control
- 📈 Data visualization with AG Grid
- 💳 Payment tracking (coming soon)
- 📅 Attendance monitoring (coming soon)
### Mobile App
- 🔐 Secure sign-in/sign-up
- 👤 User profile management
- 📱 Native mobile experience
- 🔔 Push notifications ready
- ✅ Attendance check-in (coming soon)
- 💰 Payment history (coming soon)
## Tech Stack
### Authentication
- **Clerk**: Complete authentication and user management platform
### Frontend
- **Admin**: Next.js 14 (App Router), React 19, TypeScript, Tailwind CSS
- **Mobile**: React Native, Expo SDK 54, Expo Router, TypeScript
### Backend & Database
- **Database**: SQLite with Drizzle ORM
- **API**: Next.js API Routes (REST)
### Development Tools
- **State Management**: React Query, React Hook Form
- **Validation**: Zod schemas
- **Data Grid**: AG Grid for advanced user management
- **Charts**: AG Charts for analytics and visualization
- **Testing**: Jest, Testing Library (configured)
## Project Structure
```
fitai/
├── apps/
│ ├── admin/ # Next.js admin dashboard
│ │ ├── src/
│ │ │ ├── app/ # App Router pages & API routes
│ │ │ ├── components/
│ │ │ └── lib/ # Database & utilities
│ │ └── .env.local # Admin environment variables
│ │
│ └── mobile/ # Expo React Native app
│ ├── src/
│ │ ├── app/ # Expo Router screens
│ │ │ ├── (auth)/ # Authentication screens
│ │ │ └── (tabs)/ # Main app tabs
│ │ └── components/
│ └── .env # Mobile environment variables
│
├── packages/
│ ├── database/ # Drizzle ORM schemas & DB client
│ └── shared/ # Shared types & utilities
│
└── CLERK_SETUP.md # Detailed authentication setup guide
```
+16
View File
@@ -0,0 +1,16 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).