Compare commits

..
5 Commits
Author SHA1 Message Date
ace 7427c97c9c feat: integrate NextForm logo and modernize dashboard UI with premium sports design 2025-12-10 14:45:49 +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 406 additions and 119 deletions

No files matched your search

Binary file not shown.
+47
View File
@@ -0,0 +1,47 @@
<svg viewBox="0 0 600 700" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="logoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1e40af;stop-opacity:1" />
<stop offset="50%" style="stop-color:#0369a1;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0ea5e9;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main rounded shape background -->
<path d="M 150 80 Q 350 50 450 150 L 480 300 Q 500 450 380 550 Q 200 650 100 550 Q 30 450 50 300 L 80 150 Q 100 80 150 80 Z" fill="url(#logoGrad)"/>
<!-- Arrow pointing up-right (top right) -->
<g transform="translate(340, 100)">
<path d="M 0 80 L 0 0 M 0 0 L -25 25 M 0 0 L 25 25" stroke="white" stroke-width="20" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<!-- Hand holding dumbbell (left side) -->
<g transform="translate(80, 180)">
<!-- Upper arm -->
<ellipse cx="40" cy="30" rx="25" ry="60" fill="white" opacity="0.95" transform="rotate(-30 40 30)"/>
<!-- Forearm and hand -->
<path d="M 60 80 Q 100 100 140 85" stroke="white" stroke-width="35" fill="none" stroke-linecap="round" opacity="0.95"/>
<!-- Fingers -->
<circle cx="145" cy="75" r="15" fill="white" opacity="0.95"/>
<circle cx="155" cy="90" r="12" fill="white" opacity="0.95"/>
<circle cx="150" cy="105" r="13" fill="white" opacity="0.95"/>
<!-- Dumbbell bar -->
<rect x="155" y="75" width="110" height="28" rx="14" fill="white" opacity="0.95"/>
<!-- Left weight plate -->
<circle cx="155" cy="89" r="45" fill="white" opacity="0.95"/>
<circle cx="155" cy="89" r="38" fill="url(#logoGrad)" opacity="0.4"/>
<rect x="145" y="130" width="20" height="30" fill="white" opacity="0.95" rx="3"/>
<!-- Right weight plate -->
<circle cx="265" cy="89" r="45" fill="white" opacity="0.95"/>
<circle cx="265" cy="89" r="38" fill="url(#logoGrad)" opacity="0.4"/>
<rect x="255" y="130" width="20" height="30" fill="white" opacity="0.95" rx="3"/>
</g>
<!-- Text at bottom -->
<text x="300" y="630" font-size="85" font-weight="900" fill="url(#logoGrad)" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" letter-spacing="2">NextForm</text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+35
View File
@@ -0,0 +1,35 @@
<svg viewBox="0 0 600 700" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#2563eb;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0ea5e9;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main Shape Background -->
<path d="M 150 150 L 350 150 L 450 250 L 450 450 L 350 550 L 150 550 L 50 450 L 50 250 Z" fill="url(logoGradient)" opacity="0.15"/>
<!-- Dumbbell Icon -->
<g transform="translate(200, 200)">
<!-- Left Weight -->
<circle cx="20" cy="60" r="35" fill="url(logoGradient)"/>
<rect x="10" y="70" width="20" height="80" fill="url(logoGradient)"/>
<!-- Bar -->
<rect x="35" y="100" width="130" height="25" rx="12" fill="url(logoGradient)"/>
<!-- Right Weight -->
<circle cx="180" cy="60" r="35" fill="url(logoGradient)"/>
<rect x="170" y="70" width="20" height="80" fill="url(logoGradient)"/>
<!-- Hand -->
<ellipse cx="80" cy="20" rx="25" ry="30" fill="url(logoGradient)"/>
<path d="M 70 0 Q 65 -15 60 -20" stroke="url(logoGradient)" stroke-width="8" fill="none" stroke-linecap="round"/>
<path d="M 85 -5 Q 88 -20 90 -28" stroke="url(logoGradient)" stroke-width="8" fill="none" stroke-linecap="round"/>
</g>
<!-- Arrow/Growth -->
<g transform="translate(320, 150)">
<path d="M 20 100 L 80 40 L 80 70 L 120 30 L 80 30 L 80 0 Z" fill="url(logoGradient)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -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
};
}), }),
); );
+81 -20
View File
@@ -47,57 +47,118 @@ export default function Home() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Header with Logo and App Name */}
<div className="flex items-center gap-4 pb-6 border-b border-slate-200">
<div className="w-20 h-20">
<img src="/nextform-logo.png" alt="NextForm Logo" className="w-full h-full object-contain" />
</div>
<div> <div>
<h2 className="text-3xl font-bold text-slate-900">Dashboard</h2> <h1 className="text-4xl font-black bg-gradient-to-r from-blue-600 to-cyan-600 bg-clip-text text-transparent">
<p className="text-slate-500 mt-2">Welcome back, here's what's happening today.</p> NextForm
</h1>
<p className="text-sm text-slate-500 font-medium">Fitness Management Platform</p>
</div>
</div> </div>
{/* Performance Metrics Section */}
<div className="mb-20">
<div className="mb-8">
<h2 className="text-3xl font-black text-slate-900">Performance Metrics</h2>
<p className="text-slate-600 mt-2 font-medium">Track key indicators and member activity</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<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="+5% vs last week"
trend="up" trend="up"
icon={CalendarCheck} icon={CalendarCheck}
color="green" color="emerald"
/> />
<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={CreditCard}
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"> {/* Members Directory Section */}
<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>
{/* Key Insights Section */}
<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">Active Subscriptions</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">Premium members 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-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 Activity</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 training</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">Total 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 earnings</p>
</div>
</div>
</div>
</div>
{/* Detailed Report Section */}
<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>
); );
} }
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
import { UserGrowthChart } from '@/components/charts/UserGrowthChart' import { UserGrowthChart } from '@/components/charts/UserGrowthChart'
import { MembershipDistributionChart } from '@/components/charts/MembershipDistributionChart' import { MembershipDistributionChart } from '@/components/charts/MembershipDistributionChart'
import { RevenueChart } from '@/components/charts/RevenueChart' import { RevenueChart } from '@/components/charts/RevenueChart'
import { Card, CardHeader, CardContent } from '@/components/ui/card' import { Card, CardHeader, CardContent } from '@/components/ui/Card'
interface ChartData { interface ChartData {
label: string label: string
+8 -2
View File
@@ -67,10 +67,16 @@ 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-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800">
<div className="p-6 border-b border-slate-800"> <div className="p-6 border-b border-slate-800">
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent"> <div className="flex items-center gap-3 mb-2">
FitAI Admin <div className="w-12 h-12">
<img src="/nextform-logo.png" alt="NextForm" className="w-full h-full object-contain" />
</div>
<h1 className="text-xl font-black bg-gradient-to-r from-blue-400 to-cyan-400 bg-clip-text text-transparent">
NextForm
</h1> </h1>
</div> </div>
<p className="text-xs text-slate-400">Fitness Admin</p>
</div>
<nav className="flex-1 p-4 space-y-2"> <nav className="flex-1 p-4 space-y-2">
{menuItems.map((item) => { {menuItems.map((item) => {
+44 -29
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,61 @@ 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" | "emerald" | "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", border: "border-blue-200",
orange: "bg-orange-50 text-orange-600", icon: "text-blue-700",
label: "text-blue-700",
value: "text-blue-900"
},
emerald: {
bg: "from-emerald-50 via-emerald-100 to-emerald-50",
border: "border-emerald-200",
icon: "text-emerald-700",
label: "text-emerald-700",
value: "text-emerald-900"
},
amber: {
bg: "from-amber-50 via-amber-100 to-amber-50",
border: "border-amber-200",
icon: "text-amber-700",
label: "text-amber-700",
value: "text-amber-900"
},
cyan: {
bg: "from-cyan-50 via-cyan-100 to-cyan-50",
border: "border-cyan-200",
icon: "text-cyan-700",
label: "text-cyan-700",
value: "text-cyan-900"
}
}; };
const styles = colorStyles[color];
return ( return (
<Card> <div className="bg-white rounded-3xl shadow-lg border border-slate-100 p-8 hover:shadow-xl transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <div className={`p-6 bg-gradient-to-br ${styles.bg} rounded-2xl border ${styles.border} hover:shadow-md transition-shadow`}>
<CardTitle className="text-sm font-medium text-muted-foreground"> <div className="flex items-start justify-between mb-4">
{title} <div>
</CardTitle> <p className={`text-xs ${styles.label} uppercase font-black tracking-wider`}>{title}</p>
<div className={`p-2 rounded-lg ${colorStyles[color]}`}>
<Icon size={16} />
</div> </div>
</CardHeader> <div className={`${styles.icon} opacity-60`}>
<CardContent> <Icon size={24} />
<div className="text-2xl font-bold">{value}</div> </div>
</div>
<p className={`text-4xl font-black ${styles.value} mt-4 truncate`}>{value}</p>
{change && ( {change && (
<p className="text-xs text-muted-foreground mt-1"> <p className={`text-sm ${styles.label} mt-3 font-semibold`}>
<span
className={`font-medium ${trend === "up"
? "text-green-600"
: trend === "down"
? "text-red-600"
: "text-slate-600"
}`}
>
{change} {change}
</span>{" "}
vs last month
</p> </p>
)} )}
</CardContent> </div>
</Card> </div>
); );
} }
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
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 Recommendation { interface Recommendation {
id: string; id: string;
@@ -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) =>
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { UserGrid } from "@/components/users/UserGrid"; import { UserGrid } from "@/components/users/UserGrid";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card, CardHeader, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardContent } from "@/components/ui/Card";
interface User { interface User {
id: string; id: string;
@@ -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;
@@ -444,7 +449,7 @@ export function UserManagement() {
</a> </a>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-3 gap-4">
<div> <div>
<h4 className="font-medium mb-2">Basic Information</h4> <h4 className="font-medium mb-2">Basic Information</h4>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
@@ -500,6 +505,28 @@ export function UserManagement() {
</div> </div>
</div> </div>
)} )}
<div>
<h4 className="font-medium mb-2">Check-In Statistics</h4>
<div className="space-y-1 text-sm">
<p>
<span className="font-medium">Last Check-In:</span>{" "}
{selectedUser.lastCheckInTime
? new Date(
selectedUser.lastCheckInTime,
).toLocaleString()
: "Never"}
</p>
<p>
<span className="font-medium">This Week:</span>{" "}
{selectedUser.checkInsThisWeek || 0} check-ins
</p>
<p>
<span className="font-medium">This Month:</span>{" "}
{selectedUser.checkInsThisMonth || 0} check-ins
</p>
</div>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+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 = {