Compare commits

..
6 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
18 changed files with 811 additions and 397 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}
+99 -25
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,58 +46,132 @@ 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">
{/* Premium Header */}
<div className="bg-gradient-to-r from-slate-900 via-blue-900 to-slate-900 shadow-2xl">
<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> <div>
<h2 className="text-3xl font-bold text-slate-900">Dashboard</h2> <h1 className="text-5xl font-black text-white tracking-tight">FitAI</h1>
<p className="text-slate-500 mt-2">Welcome back, here's what's happening today.</p> <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 */}
<div className="max-w-7xl mx-auto px-8 py-16">
{/* Section: Performance Metrics */}
<div className="mb-20">
<div className="mb-10">
<h2 className="text-3xl font-black text-slate-900">Performance Metrics</h2>
<p className="text-slate-600 mt-2 font-medium">Real-time insights into your platform</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<StatsCard <StatsCard
title="Total Users" title="Total Members"
value={loading ? "..." : stats.totalUsers} value={loading ? "..." : stats.totalUsers}
change="+12%" // Placeholder for now as we don't track historical growth yet change="+12% this month"
trend="up" trend="up"
icon={Users} icon={Users}
color="blue" color="blue"
/> />
<StatsCard <StatsCard
title="Active Clients" title="Active Athletes"
value={loading ? "..." : stats.activeClients} value={loading ? "..." : stats.activeClients}
change="+5%" change="+8% this week"
trend="up" trend="up"
icon={CalendarCheck} icon={Activity}
color="green" color="green"
/> />
<StatsCard <StatsCard
title="Revenue" title="Revenue"
value={loading ? "..." : formatCurrency(stats.totalRevenue)} value={loading ? "..." : formatCurrency(stats.totalRevenue)}
change={`${stats.revenueGrowth > 0 ? "+" : ""}${stats.revenueGrowth}%`} change={`${stats.revenueGrowth > 0 ? "+" : ""}${stats.revenueGrowth}% growth`}
trend={stats.revenueGrowth >= 0 ? "up" : "down"} trend={stats.revenueGrowth >= 0 ? "up" : "down"}
icon={CreditCard} icon={Zap}
color="purple" color="amber"
/> />
<StatsCard <StatsCard
title="Growth" title="Growth"
value="24%" // Placeholder value="24%"
change="-2%" change="+3% vs last month"
trend="down" trend="up"
icon={TrendingUp} icon={TrendingUp}
color="orange" color="cyan"
/> />
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<h3 className="text-xl font-bold text-slate-900 mb-6">Recent Activity</h3>
<UserManagement />
</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">
<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 /> <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>
+52 -31
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">
<p className={`text-xs ${style.label} uppercase font-black tracking-widest truncate`}>
{title} {title}
</CardTitle>
<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>
<div className={`p-3 ${style.icon} rounded-2xl flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform flex-shrink-0`}>
<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>
)} )}
</CardContent> </div>
</Card>
); );
} }
+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) =>
+245 -127
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,25 +206,83 @@ 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>
{selectedUser && (
<p className="text-sm text-blue-600 font-semibold">
Selected: {selectedUser.firstName} {selectedUser.lastName}
</p>
)}
</div>
{/* 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">
{/* Filter Row */}
<div>
<p className="text-xs font-black uppercase tracking-wider text-slate-600 mb-2">Roles</p>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setFilter("all")} onClick={() => setFilter("all")}
className={`px-2 py-1 text-xs font-semibold rounded-lg transition-all ${
filter === "all"
? "bg-blue-500 text-white shadow-md"
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
}`}
> >
All Users All
</Button> </button>
<Button <button
variant="secondary" onClick={() => setFilter("client")}
onClick={() => selectedUser && handleEditUser(selectedUser)} className={`px-2 py-1 text-xs font-semibold rounded-lg transition-all ${
disabled={!selectedUser} filter === "client"
? "bg-blue-500 text-white shadow-md"
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
}`}
> >
Edit User Clients
</Button> </button>
<Button <button
variant="secondary" onClick={() => setFilter("trainer")}
className={`px-2 py-1 text-xs font-semibold rounded-lg transition-all ${
filter === "trainer"
? "bg-blue-500 text-white shadow-md"
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
}`}
>
Trainers
</button>
<button
onClick={() => setFilter("admin")}
className={`px-2 py-1 text-xs font-semibold rounded-lg transition-all ${
filter === "admin"
? "bg-blue-500 text-white shadow-md"
: "bg-white text-slate-700 hover:bg-slate-100 border border-slate-200"
}`}
>
Admins
</button>
<button
onClick={() => setFilter("superAdmin")}
className={`px-2 py-1 text-xs font-semibold rounded-lg transition-all ${
filter === "superAdmin"
? "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={() => { onClick={() => {
setEditForm({ setEditForm({
firstName: "", firstName: "",
@@ -230,67 +294,69 @@ export function UserManagement() {
setSelectedUser(null); setSelectedUser(null);
setIsEditing(true); setIsEditing(true);
}} }}
className="px-2 py-1 text-xs font-semibold rounded-lg bg-blue-500 text-white hover:bg-blue-600"
> >
Invite User + Invite
</Button> </button>
<Button <button
variant="secondary" 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)} onClick={() => selectedUser && handleDeleteUser(selectedUser)}
disabled={!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 User Delete
</Button> </button>
<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">
variant={filter === "client" ? "primary" : "secondary"}
onClick={() => setFilter("client")}
>
Clients
</Button>
<Button
variant={filter === "trainer" ? "primary" : "secondary"}
onClick={() => setFilter("trainer")}
>
Trainers
</Button>
<Button
variant={filter === "admin" ? "primary" : "secondary"}
onClick={() => setFilter("admin")}
>
Admins
</Button>
<Button
variant={filter === "superAdmin" ? "primary" : "secondary"}
onClick={() => setFilter("superAdmin")}
>
Super Admins
</Button>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600">
Showing {users.length} users
{selectedUser && (
<span className="ml-4 text-blue-600">
Selected: {selectedUser.firstName} {selectedUser.lastName}
</span>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleRefresh}>
Refresh Refresh
</Button> </button>
<Button variant="secondary" onClick={handleExport}> <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 CSV Export
</Button> </button>
</div>
</div> </div>
</div> </div>
<Card> {/* Tab Navigation */}
<div className="flex gap-2 border-b border-slate-200">
<button
onClick={() => setActiveTab("users")}
className={`px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === "users"
? "border-blue-500 text-blue-600"
: "border-transparent text-slate-600 hover:text-slate-900"
}`}
>
User List ({users.length})
</button>
<button
onClick={() => setActiveTab("details")}
disabled={!selectedUser}
className={`px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
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>
{/* Tab Content */}
{activeTab === "users" && (
<Card className="border border-slate-200">
<CardContent className="p-0"> <CardContent className="p-0">
<UserGrid <UserGrid
users={users} users={users}
onUserSelect={(user) => handleUserSelect(user)} onUserSelect={(user) => {
handleUserSelect(user);
setActiveTab("details");
}}
onEditUser={handleEditUser} onEditUser={handleEditUser}
onDeleteUser={handleDeleteUser} onDeleteUser={handleDeleteUser}
onBulkDelete={handleBulkDelete} onBulkDelete={handleBulkDelete}
@@ -298,6 +364,70 @@ export function UserManagement() {
/> />
</CardContent> </CardContent>
</Card> </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">
<a
href={`/users/${selectedUser.id}`}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm font-medium"
>
View Full Profile & Recommendations
</a>
</CardHeader>
<CardContent>
<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} {selectedUser.firstName} {selectedUser.lastName}
</p> </h3>
<p> <p className="text-sm text-slate-600 mt-1">{selectedUser.email}</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>
<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> </div>
{selectedUser.client && ( {selectedUser.client && (
<div> <div className="space-y-3">
<h4 className="font-medium mb-2">Client Information</h4> <p className="text-xs font-black uppercase tracking-wider text-slate-600">Membership</p>
<div className="space-y-1 text-sm"> <p className="text-base font-semibold text-slate-900">{selectedUser.client.membershipType}</p>
<p> <p className="text-xs font-black uppercase tracking-wider text-slate-600 mt-4">Status</p>
<span className="font-medium">Membership:</span>{" "} <p className="text-base font-semibold text-slate-900">{selectedUser.client.membershipStatus}</p>
{selectedUser.client.membershipType} </div>
</p> )}
<p>
<span className="font-medium">Status:</span>{" "} <div className="space-y-3">
{selectedUser.client.membershipStatus} <p className="text-xs font-black uppercase tracking-wider text-slate-600">Check-ins This Month</p>
</p> <p className="text-2xl font-black text-blue-600">{selectedUser.checkInsThisMonth || 0}</p>
<p> <p className="text-xs font-black uppercase tracking-wider text-slate-600 mt-4">Last Check-in</p>
<span className="font-medium">Member Since:</span>{" "} <p className="text-sm text-slate-900">
{new Date( {selectedUser.lastCheckInTime
selectedUser.client.joinDate, ? new Date(selectedUser.lastCheckInTime).toLocaleDateString()
).toLocaleDateString()}
</p>
<p>
<span className="font-medium">Last Visit:</span>{" "}
{selectedUser.client.lastVisit
? new Date(
selectedUser.client.lastVisit,
).toLocaleDateString()
: "Never"} : "Never"}
</p> </p>
</div> </div>
</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
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> </div>
</CardContent>
</Card>
)} )}
</div> </div>
); );
+68 -23
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
// Check if old table exists and migrate
const tableInfo = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'").get() as any;
if (tableInfo) {
// Check if table has clientId column (old schema)
const columns = this.db.prepare("PRAGMA table_info(attendance)").all() as any[];
const hasClientId = columns.some((col: any) => col.name === 'clientId');
if (hasClientId) {
console.log('Migrating attendance table from clientId to userId...');
// Create new table with userId
this.db.exec(` this.db.exec(`
CREATE TABLE IF NOT EXISTS attendance ( CREATE TABLE IF NOT EXISTS attendance_new (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
clientId TEXT NOT NULL, userId TEXT NOT NULL,
checkInTime DATETIME NOT NULL, checkInTime DATETIME NOT NULL,
checkOutTime DATETIME, checkOutTime DATETIME,
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')), type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
notes TEXT, notes TEXT,
createdAt DATETIME NOT NULL, createdAt DATETIME NOT NULL,
FOREIGN KEY (clientId) REFERENCES clients (id) ON DELETE CASCADE 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
const client = await this.getClientByUserId(userId);
if (client) {
this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run( this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run(
now.toISOString(), now.toISOString(),
clientId 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(
+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).