redesign
admin dashboard
This commit is contained in:
parent
39021dca35
commit
b1bb5d8166
@ -12,7 +12,6 @@
|
|||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib",
|
"utils": "@/lib/utils"
|
||||||
"ui": "@/components/ui"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
1142
apps/admin/package-lock.json
generated
1142
apps/admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -14,7 +14,7 @@
|
|||||||
"@clerk/nextjs": "^6.34.5",
|
"@clerk/nextjs": "^6.34.5",
|
||||||
"@fitai/shared": "file:../../packages/shared",
|
"@fitai/shared": "file:../../packages/shared",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tanstack/react-query": "^5.90.7",
|
"@tanstack/react-query": "^5.90.7",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/sqlite3": "^5.1.0",
|
"@types/sqlite3": "^5.1.0",
|
||||||
@ -22,13 +22,15 @@
|
|||||||
"ag-charts-react": "^12.3.1",
|
"ag-charts-react": "^12.3.1",
|
||||||
"ag-grid-community": "^34.3.1",
|
"ag-grid-community": "^34.3.1",
|
||||||
"ag-grid-react": "^34.3.1",
|
"ag-grid-react": "^34.3.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.16",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.1",
|
"next": "^16.0.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.4.31",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
@ -36,7 +38,9 @@
|
|||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"svix": "^1.81.0",
|
"svix": "^1.81.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
19
apps/admin/scripts/migrate-last-visit.js
Normal file
19
apps/admin/scripts/migrate-last-visit.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(process.cwd(), 'data', 'fitai.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Attempting to add lastVisit column to clients table...');
|
||||||
|
db.exec('ALTER TABLE clients ADD COLUMN lastVisit DATETIME');
|
||||||
|
console.log('Successfully added lastVisit column.');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('duplicate column name')) {
|
||||||
|
console.log('Column lastVisit already exists.');
|
||||||
|
} else {
|
||||||
|
console.error('Error adding column:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
18
apps/admin/src/app/api/admin/stats/route.ts
Normal file
18
apps/admin/src/app/api/admin/stats/route.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { auth } from '@clerk/nextjs/server'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getDatabase } from '@/lib/database'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth()
|
||||||
|
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
|
||||||
|
const db = await getDatabase()
|
||||||
|
const stats = await db.getDashboardStats()
|
||||||
|
|
||||||
|
return NextResponse.json(stats)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard stats error:', error)
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,76 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
SignInButton,
|
SignInButton,
|
||||||
UserButton,
|
UserButton,
|
||||||
} from "@clerk/nextjs";
|
} from "@clerk/nextjs";
|
||||||
|
import { Sidebar } from "@/components/ui/Sidebar";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
@ -25,26 +26,12 @@ export default function RootLayout({
|
|||||||
<ClerkProvider>
|
<ClerkProvider>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<header className="bg-white shadow-sm border-b">
|
<div className="flex min-h-screen bg-slate-50">
|
||||||
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
|
<Sidebar />
|
||||||
<div className="flex items-center gap-2">
|
<main className="flex-1 ml-64 p-8">
|
||||||
<h1 className="text-xl font-bold text-gray-900">FitAI Admin</h1>
|
{children}
|
||||||
</div>
|
</main>
|
||||||
<div className="flex items-center gap-4">
|
</div>
|
||||||
<SignedIn>
|
|
||||||
<UserButton afterSignOutUrl="/" />
|
|
||||||
</SignedIn>
|
|
||||||
<SignedOut>
|
|
||||||
<SignInButton mode="modal">
|
|
||||||
<button className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
</SignInButton>
|
|
||||||
</SignedOut>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{children}
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</ClerkProvider>
|
</ClerkProvider>
|
||||||
|
|||||||
@ -1,68 +1,103 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Users, CreditCard, CalendarCheck, TrendingUp } from "lucide-react";
|
||||||
|
import { StatsCard } from "@/components/ui/StatsCard";
|
||||||
import { UserManagement } from "@/components/users/UserManagement";
|
import { UserManagement } from "@/components/users/UserManagement";
|
||||||
import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard";
|
import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
totalUsers: number;
|
||||||
|
activeClients: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
revenueGrowth: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
|
totalUsers: 0,
|
||||||
|
activeClients: 0,
|
||||||
|
totalRevenue: 0,
|
||||||
|
revenueGrowth: 0,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get("/api/admin/stats");
|
||||||
|
setStats(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch dashboard stats:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gray-50">
|
<div className="space-y-8">
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div>
|
||||||
<div className="flex justify-between items-center mb-8">
|
<h2 className="text-3xl font-bold text-slate-900">Dashboard</h2>
|
||||||
<h1 className="text-4xl font-bold text-gray-900">
|
<p className="text-slate-500 mt-2">Welcome back, here's what's happening today.</p>
|
||||||
FitAI Admin Dashboard
|
</div>
|
||||||
</h1>
|
|
||||||
<nav className="flex gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<Link
|
<StatsCard
|
||||||
href="/users"
|
title="Total Users"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
value={loading ? "..." : stats.totalUsers}
|
||||||
>
|
change="+12%" // Placeholder for now as we don't track historical growth yet
|
||||||
User Management
|
trend="up"
|
||||||
</Link>
|
icon={Users}
|
||||||
<Link
|
color="blue"
|
||||||
href="/analytics"
|
/>
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
<StatsCard
|
||||||
>
|
title="Active Clients"
|
||||||
Analytics
|
value={loading ? "..." : stats.activeClients}
|
||||||
</Link>
|
change="+5%"
|
||||||
</nav>
|
trend="up"
|
||||||
|
icon={CalendarCheck}
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Revenue"
|
||||||
|
value={loading ? "..." : formatCurrency(stats.totalRevenue)}
|
||||||
|
change={`${stats.revenueGrowth > 0 ? "+" : ""}${stats.revenueGrowth}%`}
|
||||||
|
trend={stats.revenueGrowth >= 0 ? "up" : "down"}
|
||||||
|
icon={CreditCard}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Growth"
|
||||||
|
value="24%" // Placeholder
|
||||||
|
change="-2%"
|
||||||
|
trend="down"
|
||||||
|
icon={TrendingUp}
|
||||||
|
color="orange"
|
||||||
|
/>
|
||||||
|
</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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<h3 className="text-xl font-bold text-slate-900 mb-6">Quick Analytics</h3>
|
||||||
<h2 className="text-xl font-semibold mb-4">Client Management</h2>
|
<AnalyticsDashboard />
|
||||||
<p className="text-gray-600">
|
|
||||||
Manage fitness clients and their profiles
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Payment Tracking</h2>
|
|
||||||
<p className="text-gray-600">Monitor payments and subscriptions</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Attendance</h2>
|
|
||||||
<p className="text-gray-600">Track client attendance and habits</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-2xl font-semibold mb-6">
|
|
||||||
Recent User Activity
|
|
||||||
</h2>
|
|
||||||
<div>
|
|
||||||
<UserManagement />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-2xl font-semibold mb-6">Quick Analytics</h2>
|
|
||||||
<div>
|
|
||||||
<AnalyticsDashboard />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
71
apps/admin/src/components/ui/Sidebar.tsx
Normal file
71
apps/admin/src/components/ui/Sidebar.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
CalendarCheck,
|
||||||
|
CreditCard,
|
||||||
|
Settings,
|
||||||
|
LogOut
|
||||||
|
} from "lucide-react";
|
||||||
|
import { UserButton, useUser } from "@clerk/nextjs";
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ icon: LayoutDashboard, label: "Dashboard", href: "/" },
|
||||||
|
{ icon: Users, label: "Users", href: "/users" },
|
||||||
|
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
|
||||||
|
{ icon: CreditCard, label: "Payments", href: "/payments" },
|
||||||
|
{ icon: Settings, label: "Settings", href: "/settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { user } = useUser();
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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">
|
||||||
|
FitAI Admin
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-2">
|
||||||
|
{menuItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 group ${isActive
|
||||||
|
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
|
||||||
|
: "text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-white"} />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-slate-800">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 rounded-lg bg-slate-800/50">
|
||||||
|
<UserButton afterSignOutUrl="/" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{user?.fullName || "Admin User"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 truncate">
|
||||||
|
{user?.primaryEmailAddress?.emailAddress}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/admin/src/components/ui/StatsCard.tsx
Normal file
51
apps/admin/src/components/ui/StatsCard.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
interface StatsCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
change?: string;
|
||||||
|
trend?: "up" | "down" | "neutral";
|
||||||
|
icon: LucideIcon;
|
||||||
|
color?: "blue" | "green" | "purple" | "orange";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsCard({ title, value, change, trend, icon: Icon, color = "blue" }: StatsCardProps) {
|
||||||
|
const colorStyles = {
|
||||||
|
blue: "bg-blue-50 text-blue-600",
|
||||||
|
green: "bg-green-50 text-green-600",
|
||||||
|
purple: "bg-purple-50 text-purple-600",
|
||||||
|
orange: "bg-orange-50 text-orange-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/admin/src/components/ui/card.tsx
Normal file
76
apps/admin/src/components/ui/card.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
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 }
|
||||||
@ -69,6 +69,7 @@ export function UserGrid({
|
|||||||
filter: "agTextColumnFilter",
|
filter: "agTextColumnFilter",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
cellRenderer: (params: any) => {
|
cellRenderer: (params: any) => {
|
||||||
|
if (!params.value) return null;
|
||||||
const roleColors = {
|
const roleColors = {
|
||||||
admin: "bg-purple-100 text-purple-800",
|
admin: "bg-purple-100 text-purple-800",
|
||||||
trainer: "bg-blue-100 text-blue-800",
|
trainer: "bg-blue-100 text-blue-800",
|
||||||
@ -77,7 +78,14 @@ export function UserGrid({
|
|||||||
const colorClass =
|
const colorClass =
|
||||||
roleColors[params.value as keyof typeof roleColors] ||
|
roleColors[params.value as keyof typeof roleColors] ||
|
||||||
"bg-gray-100 text-gray-800";
|
"bg-gray-100 text-gray-800";
|
||||||
return `<span class="px-2 py-1 rounded text-xs font-medium ${colorClass}">${params.value}</span>`;
|
|
||||||
|
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
},
|
},
|
||||||
@ -87,6 +95,7 @@ export function UserGrid({
|
|||||||
filter: "agTextColumnFilter",
|
filter: "agTextColumnFilter",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
minWidth: 130,
|
minWidth: 130,
|
||||||
|
valueFormatter: (params: any) => params.value || "N/A",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: "Membership",
|
headerName: "Membership",
|
||||||
@ -94,17 +103,24 @@ export function UserGrid({
|
|||||||
filter: "agTextColumnFilter",
|
filter: "agTextColumnFilter",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
cellRenderer: (params: any) => {
|
cellRenderer: (params: any) => {
|
||||||
if (!params.value || params.value === "N/A") return "N/A";
|
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
|
||||||
|
|
||||||
const membershipColors = {
|
const membershipColors = {
|
||||||
vip: "bg-yellow-100 text-yellow-800",
|
vip: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||||
premium: "bg-blue-100 text-blue-800",
|
premium: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
basic: "bg-gray-100 text-gray-800",
|
basic: "bg-slate-100 text-slate-800 border-slate-200",
|
||||||
};
|
};
|
||||||
const colorClass =
|
const colorClass =
|
||||||
membershipColors[params.value as keyof typeof membershipColors] ||
|
membershipColors[params.value as keyof typeof membershipColors] ||
|
||||||
"bg-gray-100 text-gray-800";
|
"bg-gray-100 text-gray-800";
|
||||||
return `<span class="px-2 py-1 rounded text-xs font-medium ${colorClass}">${params.value}</span>`;
|
|
||||||
|
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
},
|
},
|
||||||
@ -114,17 +130,25 @@ export function UserGrid({
|
|||||||
filter: "agTextColumnFilter",
|
filter: "agTextColumnFilter",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
cellRenderer: (params: any) => {
|
cellRenderer: (params: any) => {
|
||||||
if (!params.value || params.value === "N/A") return "N/A";
|
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
|
||||||
|
|
||||||
const statusColors = {
|
const statusColors = {
|
||||||
active: "bg-green-100 text-green-800",
|
active: "bg-green-100 text-green-800",
|
||||||
inactive: "bg-red-100 text-red-800",
|
inactive: "bg-red-100 text-red-800",
|
||||||
suspended: "bg-yellow-100 text-yellow-800",
|
suspended: "bg-orange-100 text-orange-800",
|
||||||
|
expired: "bg-gray-100 text-gray-800",
|
||||||
};
|
};
|
||||||
const colorClass =
|
const colorClass =
|
||||||
statusColors[params.value as keyof typeof statusColors] ||
|
statusColors[params.value as keyof typeof statusColors] ||
|
||||||
"bg-gray-100 text-gray-800";
|
"bg-gray-100 text-gray-800";
|
||||||
return `<span class="px-2 py-1 rounded text-xs font-medium ${colorClass}">${params.value}</span>`;
|
|
||||||
|
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -370,6 +370,12 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
attendance.type, attendance.notes, attendance.createdAt.toISOString()
|
attendance.type, attendance.notes, attendance.createdAt.toISOString()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Update client last visit
|
||||||
|
this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run(
|
||||||
|
now.toISOString(),
|
||||||
|
clientId
|
||||||
|
)
|
||||||
|
|
||||||
return attendance
|
return attendance
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -432,7 +438,8 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
membershipType: row.membershipType,
|
membershipType: row.membershipType,
|
||||||
membershipStatus: row.membershipStatus,
|
membershipStatus: row.membershipStatus,
|
||||||
joinDate: new Date(row.joinDate)
|
joinDate: new Date(row.joinDate),
|
||||||
|
lastVisit: row.lastVisit ? new Date(row.lastVisit) : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -464,4 +471,43 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
createdAt: new Date(row.createdAt)
|
createdAt: new Date(row.createdAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDashboardStats(): Promise<{
|
||||||
|
totalUsers: number;
|
||||||
|
activeClients: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
revenueGrowth: number;
|
||||||
|
}> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
// Total Users
|
||||||
|
const userCountStmt = this.db.prepare('SELECT COUNT(*) as count FROM users')
|
||||||
|
const userCount = (userCountStmt.get() as any).count
|
||||||
|
|
||||||
|
// Active Clients
|
||||||
|
const activeClientCountStmt = this.db.prepare("SELECT COUNT(*) as count FROM clients WHERE membershipStatus = 'active'")
|
||||||
|
const activeClientCount = (activeClientCountStmt.get() as any).count
|
||||||
|
|
||||||
|
// Total Revenue (assuming payments table exists, handling if it's empty)
|
||||||
|
// Note: We need to create the payments table first if it doesn't exist in createTables
|
||||||
|
// For now, returning 0 if table doesn't exist or is empty
|
||||||
|
let totalRevenue = 0
|
||||||
|
let revenueGrowth = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const revenueStmt = this.db.prepare('SELECT SUM(amount) as total FROM payments WHERE status = "completed"')
|
||||||
|
const revenueResult = revenueStmt.get() as any
|
||||||
|
totalRevenue = revenueResult?.total || 0
|
||||||
|
} catch (e) {
|
||||||
|
// Table might not exist yet
|
||||||
|
console.warn('Payments table query failed, returning 0 revenue')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers: userCount,
|
||||||
|
activeClients: activeClientCount,
|
||||||
|
totalRevenue,
|
||||||
|
revenueGrowth
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -17,6 +17,7 @@ export interface Client {
|
|||||||
membershipType: "basic" | "premium" | "vip";
|
membershipType: "basic" | "premium" | "vip";
|
||||||
membershipStatus: "active" | "inactive" | "expired";
|
membershipStatus: "active" | "inactive" | "expired";
|
||||||
joinDate: Date;
|
joinDate: Date;
|
||||||
|
lastVisit?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FitnessProfile {
|
export interface FitnessProfile {
|
||||||
@ -88,6 +89,14 @@ export interface IDatabase {
|
|||||||
getAttendanceHistory(clientId: string): Promise<Attendance[]>;
|
getAttendanceHistory(clientId: string): Promise<Attendance[]>;
|
||||||
getAllAttendance(): Promise<Attendance[]>;
|
getAllAttendance(): Promise<Attendance[]>;
|
||||||
getActiveCheckIn(clientId: string): Promise<Attendance | null>;
|
getActiveCheckIn(clientId: string): Promise<Attendance | null>;
|
||||||
|
|
||||||
|
// Dashboard operations
|
||||||
|
getDashboardStats(): Promise<{
|
||||||
|
totalUsers: number;
|
||||||
|
activeClients: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
revenueGrowth: number; // Percentage vs last month
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database configuration
|
// Database configuration
|
||||||
|
|||||||
@ -1,26 +1,14 @@
|
|||||||
export const formatDate = (date: Date): string => {
|
import { type ClassValue, clsx } from "clsx"
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
import { twMerge } from "tailwind-merge"
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
export function cn(...inputs: ClassValue[]) {
|
||||||
day: 'numeric',
|
return twMerge(clsx(inputs))
|
||||||
}).format(date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatCurrency = (
|
export function formatDate(date: string | Date) {
|
||||||
amount: number,
|
return new Date(date).toLocaleDateString("en-US", {
|
||||||
currency: string = 'USD'
|
month: "long",
|
||||||
): string => {
|
day: "numeric",
|
||||||
return new Intl.NumberFormat('en-US', {
|
year: "numeric",
|
||||||
style: 'currency',
|
})
|
||||||
currency,
|
|
||||||
}).format(amount)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const calculateDaysBetween = (startDate: Date, endDate: Date): number => {
|
|
||||||
const timeDiff = endDate.getTime() - startDate.getTime()
|
|
||||||
return Math.ceil(timeDiff / (1000 * 3600 * 24))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const generateId = (): string => {
|
|
||||||
return Math.random().toString(36).substr(2, 9)
|
|
||||||
}
|
}
|
||||||
@ -1,21 +1,76 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
darkMode: ["class"],
|
||||||
content: [
|
content: [
|
||||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
'./pages/**/*.{ts,tsx}',
|
||||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
'./components/**/*.{ts,tsx}',
|
||||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
'./app/**/*.{ts,tsx}',
|
||||||
|
'./src/**/*.{ts,tsx}',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
primary: {
|
primary: {
|
||||||
50: '#eff6ff',
|
DEFAULT: "hsl(var(--primary))",
|
||||||
500: '#3b82f6',
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
600: '#2563eb',
|
|
||||||
700: '#1d4ed8',
|
|
||||||
},
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: 0 },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require("tailwindcss-animate")],
|
||||||
}
|
}
|
||||||
3
apps/admin/userFlow.md
Normal file
3
apps/admin/userFlow.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
## user flow
|
||||||
|
|
||||||
|
superAdmin -> localAdmin/Gym -> localAdmin -> creates trainers -> can add users
|
||||||
Loading…
Reference in New Issue
Block a user