manual check in/check out
implemented, to be implemented: qrcode or/and geofence
This commit is contained in:
parent
33a698066f
commit
39021dca35
Binary file not shown.
11
apps/admin/package-lock.json
generated
11
apps/admin/package-lock.json
generated
@ -22,6 +22,7 @@
|
|||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"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.5.6",
|
||||||
@ -4931,6 +4932,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"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.5.6",
|
||||||
|
|||||||
23
apps/admin/src/app/api/admin/attendance/route.ts
Normal file
23
apps/admin/src/app/api/admin/attendance/route.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { auth } from '@clerk/nextjs/server'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getDatabase } from '@/lib/database'
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth()
|
||||||
|
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
|
||||||
|
const db = await getDatabase()
|
||||||
|
const user = await db.getUserById(userId)
|
||||||
|
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return new NextResponse('Forbidden', { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendance = await db.getAllAttendance()
|
||||||
|
return NextResponse.json(attendance)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin attendance error:', error)
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/admin/src/app/api/attendance/check-in/route.ts
Normal file
44
apps/admin/src/app/api/attendance/check-in/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { auth } from '@clerk/nextjs/server'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getDatabase } from '@/lib/database'
|
||||||
|
import { ensureUserSynced } from '@/lib/sync-user'
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth()
|
||||||
|
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
|
||||||
|
const db = await getDatabase()
|
||||||
|
|
||||||
|
// Ensure user exists in DB (sync from Clerk if needed)
|
||||||
|
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
|
||||||
|
const activeCheckIn = await db.getActiveCheckIn(client.id)
|
||||||
|
if (activeCheckIn) {
|
||||||
|
return new NextResponse('Already checked in', { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json()
|
||||||
|
const { type = 'gym', notes } = body
|
||||||
|
|
||||||
|
const attendance = await db.checkIn(client.id, type, notes)
|
||||||
|
return NextResponse.json(attendance)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check-in error:', error)
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/admin/src/app/api/attendance/check-out/route.ts
Normal file
28
apps/admin/src/app/api/attendance/check-out/route.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { auth } from '@clerk/nextjs/server'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getDatabase } from '@/lib/database'
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const { userId } = await auth()
|
||||||
|
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
|
||||||
|
const db = await getDatabase()
|
||||||
|
const client = await db.getClientByUserId(userId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return new NextResponse('Client profile not found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCheckIn = await db.getActiveCheckIn(client.id)
|
||||||
|
if (!activeCheckIn) {
|
||||||
|
return new NextResponse('No active check-in found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const attendance = await db.checkOut(activeCheckIn.id)
|
||||||
|
return NextResponse.json(attendance)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check-out error:', error)
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/admin/src/app/api/attendance/history/route.ts
Normal file
44
apps/admin/src/app/api/attendance/history/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { auth } from '@clerk/nextjs/server'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { getDatabase } from '@/lib/database'
|
||||||
|
import { ensureUserSynced } from '@/lib/sync-user'
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
console.log('GET /api/attendance/history called')
|
||||||
|
console.log('Headers:', Object.fromEntries(req.headers.entries()))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authResult = await auth()
|
||||||
|
const { userId } = authResult
|
||||||
|
console.log('Auth result:', JSON.stringify(authResult, null, 2))
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
console.log('No userId found in auth result')
|
||||||
|
return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDatabase()
|
||||||
|
|
||||||
|
// Ensure user exists in DB (sync from Clerk if needed)
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await db.getAttendanceHistory(client.id)
|
||||||
|
return NextResponse.json(history)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('History error:', error)
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/admin/src/app/api/health/route.ts
Normal file
5
apps/admin/src/app/api/health/route.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||||
|
}
|
||||||
96
apps/admin/src/app/attendance/page.tsx
Normal file
96
apps/admin/src/app/attendance/page.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
|
||||||
|
interface Attendance {
|
||||||
|
id: string
|
||||||
|
clientId: string
|
||||||
|
checkInTime: string
|
||||||
|
checkOutTime?: string
|
||||||
|
type: string
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AttendancePage() {
|
||||||
|
const [attendance, setAttendance] = useState<Attendance[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAttendance = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/attendance')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setAttendance(data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching attendance:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAttendance()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-8">Loading...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Attendance Monitoring</h1>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Client ID
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Check In
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Check Out
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{attendance.map((record) => (
|
||||||
|
<tr key={record.id}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{record.clientId}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">
|
||||||
|
{record.type}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{format(new Date(record.checkInTime), 'PP p')}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{record.checkOutTime ? format(new Date(record.checkOutTime), 'PP p') : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${record.checkOutTime
|
||||||
|
? 'bg-gray-100 text-gray-800'
|
||||||
|
: 'bg-green-100 text-green-800'
|
||||||
|
}`}>
|
||||||
|
{record.checkOutTime ? 'Completed' : 'Active'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,37 +0,0 @@
|
|||||||
export interface User {
|
|
||||||
id: string
|
|
||||||
email: string
|
|
||||||
firstName: string
|
|
||||||
lastName: string
|
|
||||||
password: string
|
|
||||||
phone?: string
|
|
||||||
role: 'admin' | 'client'
|
|
||||||
createdAt: Date
|
|
||||||
updatedAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Client {
|
|
||||||
id: string
|
|
||||||
userId: string
|
|
||||||
membershipType: 'basic' | 'premium' | 'vip'
|
|
||||||
membershipStatus: 'active' | 'inactive' | 'expired'
|
|
||||||
joinDate: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FitnessProfile {
|
|
||||||
userId: string
|
|
||||||
height: string
|
|
||||||
weight: string
|
|
||||||
age: string
|
|
||||||
gender: 'male' | 'female' | 'other'
|
|
||||||
activityLevel: 'sedentary' | 'light' | 'moderate' | 'active' | 'very_active'
|
|
||||||
fitnessGoals: string[]
|
|
||||||
exerciseHabits: string
|
|
||||||
dietHabits: string
|
|
||||||
medicalConditions: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory database
|
|
||||||
export const users: User[] = []
|
|
||||||
export const clients: Client[] = []
|
|
||||||
export const fitnessProfiles: FitnessProfile[] = []
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { IDatabase, User, Client, FitnessProfile, DatabaseConfig } from './types'
|
import { IDatabase, User, Client, FitnessProfile, Attendance, DatabaseConfig } from './types'
|
||||||
|
|
||||||
export class SQLiteDatabase implements IDatabase {
|
export class SQLiteDatabase implements IDatabase {
|
||||||
private db: Database.Database | null = null
|
private db: Database.Database | null = null
|
||||||
@ -103,18 +103,37 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_clients_userId ON clients(userId);
|
CREATE INDEX IF NOT EXISTS idx_clients_userId ON clients(userId);
|
||||||
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
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS attendance (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
clientId 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 (clientId) REFERENCES clients (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attendance_clientId ON attendance(clientId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// User operations
|
// User operations
|
||||||
async createUser(userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
|
async createUser(userData: Omit<User, 'createdAt' | 'updatedAt'>): Promise<User> {
|
||||||
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 = userData.id || Math.random().toString(36).substr(2, 9)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
const user: User = {
|
const user: User = {
|
||||||
id,
|
|
||||||
...userData,
|
...userData,
|
||||||
|
id,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
}
|
}
|
||||||
@ -325,6 +344,73 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
return (result.changes || 0) > 0
|
return (result.changes || 0) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attendance operations
|
||||||
|
async checkIn(clientId: string, type: 'gym' | 'class' | 'personal_training', notes?: string): Promise<Attendance> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const id = Math.random().toString(36).substr(2, 9)
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const attendance: Attendance = {
|
||||||
|
id,
|
||||||
|
clientId,
|
||||||
|
checkInTime: now,
|
||||||
|
type,
|
||||||
|
notes,
|
||||||
|
createdAt: now
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(
|
||||||
|
`INSERT INTO attendance (id, clientId, checkInTime, type, notes, createdAt)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
attendance.id, attendance.clientId, attendance.checkInTime.toISOString(),
|
||||||
|
attendance.type, attendance.notes, attendance.createdAt.toISOString()
|
||||||
|
)
|
||||||
|
|
||||||
|
return attendance
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkOut(attendanceId: string): Promise<Attendance | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const stmt = this.db.prepare('UPDATE attendance SET checkOutTime = ? WHERE id = ?')
|
||||||
|
stmt.run(now.toISOString(), attendanceId)
|
||||||
|
|
||||||
|
return this.getAttendanceById(attendanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttendanceById(id: string): Promise<Attendance | null> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM attendance WHERE id = ?')
|
||||||
|
const row = stmt.get(id)
|
||||||
|
return row ? this.mapRowToAttendance(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttendanceHistory(clientId: string): Promise<Attendance[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? ORDER BY checkInTime DESC')
|
||||||
|
const rows = stmt.all(clientId)
|
||||||
|
return rows.map(row => this.mapRowToAttendance(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAttendance(): Promise<Attendance[]> {
|
||||||
|
if (!this.db) throw new Error('Database not connected')
|
||||||
|
const stmt = this.db.prepare('SELECT * FROM attendance ORDER BY checkInTime DESC')
|
||||||
|
const rows = stmt.all()
|
||||||
|
return rows.map(row => this.mapRowToAttendance(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveCheckIn(clientId: string): Promise<Attendance | null> {
|
||||||
|
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 row = stmt.get(clientId)
|
||||||
|
return row ? this.mapRowToAttendance(row) : null
|
||||||
|
}
|
||||||
|
|
||||||
// Helper methods to map database rows to entities
|
// Helper methods to map database rows to entities
|
||||||
private mapRowToUser(row: any): User {
|
private mapRowToUser(row: any): User {
|
||||||
return {
|
return {
|
||||||
@ -366,4 +452,16 @@ export class SQLiteDatabase implements IDatabase {
|
|||||||
updatedAt: new Date(row.updatedAt)
|
updatedAt: new Date(row.updatedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mapRowToAttendance(row: any): Attendance {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
clientId: row.clientId,
|
||||||
|
checkInTime: new Date(row.checkInTime),
|
||||||
|
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
|
||||||
|
type: row.type,
|
||||||
|
notes: row.notes,
|
||||||
|
createdAt: new Date(row.createdAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -34,6 +34,16 @@ export interface FitnessProfile {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Attendance {
|
||||||
|
id: string;
|
||||||
|
clientId: string;
|
||||||
|
checkInTime: Date;
|
||||||
|
checkOutTime?: Date;
|
||||||
|
type: "gym" | "class" | "personal_training";
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
// Database Interface - allows us to swap implementations
|
// Database Interface - allows us to swap implementations
|
||||||
export interface IDatabase {
|
export interface IDatabase {
|
||||||
// Connection management
|
// Connection management
|
||||||
@ -41,7 +51,7 @@ export interface IDatabase {
|
|||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
|
|
||||||
// User operations
|
// User operations
|
||||||
createUser(user: Omit<User, "id" | "createdAt" | "updatedAt">): Promise<User>;
|
createUser(user: Omit<User, "createdAt" | "updatedAt">): Promise<User>;
|
||||||
getUserById(id: string): Promise<User | null>;
|
getUserById(id: string): Promise<User | null>;
|
||||||
getUserByEmail(email: string): Promise<User | null>;
|
getUserByEmail(email: string): Promise<User | null>;
|
||||||
getAllUsers(): Promise<User[]>;
|
getAllUsers(): Promise<User[]>;
|
||||||
@ -67,6 +77,17 @@ export interface IDatabase {
|
|||||||
updates: Partial<FitnessProfile>,
|
updates: Partial<FitnessProfile>,
|
||||||
): Promise<FitnessProfile | null>;
|
): Promise<FitnessProfile | null>;
|
||||||
deleteFitnessProfile(userId: string): Promise<boolean>;
|
deleteFitnessProfile(userId: string): Promise<boolean>;
|
||||||
|
|
||||||
|
// Attendance operations
|
||||||
|
checkIn(
|
||||||
|
clientId: string,
|
||||||
|
type: "gym" | "class" | "personal_training",
|
||||||
|
notes?: string,
|
||||||
|
): Promise<Attendance>;
|
||||||
|
checkOut(attendanceId: string): Promise<Attendance | null>;
|
||||||
|
getAttendanceHistory(clientId: string): Promise<Attendance[]>;
|
||||||
|
getAllAttendance(): Promise<Attendance[]>;
|
||||||
|
getActiveCheckIn(clientId: string): Promise<Attendance | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database configuration
|
// Database configuration
|
||||||
|
|||||||
29
apps/admin/src/lib/sync-user.ts
Normal file
29
apps/admin/src/lib/sync-user.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { currentUser } from '@clerk/nextjs/server'
|
||||||
|
import { IDatabase } from './database/types'
|
||||||
|
|
||||||
|
export async function ensureUserSynced(userId: string, db: IDatabase) {
|
||||||
|
const existingUser = await db.getUserById(userId)
|
||||||
|
if (existingUser) return existingUser
|
||||||
|
|
||||||
|
console.log('User not found in DB, syncing from Clerk:', userId)
|
||||||
|
const clerkUser = await currentUser()
|
||||||
|
|
||||||
|
if (!clerkUser || clerkUser.id !== userId) {
|
||||||
|
throw new Error('Could not fetch Clerk user details')
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = clerkUser.emailAddresses[0]?.emailAddress
|
||||||
|
if (!email) throw new Error('User has no email')
|
||||||
|
|
||||||
|
const user = await db.createUser({
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
firstName: clerkUser.firstName || '',
|
||||||
|
lastName: clerkUser.lastName || '',
|
||||||
|
password: '', // Managed by Clerk
|
||||||
|
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
|
||||||
|
role: (clerkUser.publicMetadata.role as 'admin' | 'client') || 'client'
|
||||||
|
})
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ const isPublicRoute = createRouteMatcher([
|
|||||||
"/sign-in(.*)",
|
"/sign-in(.*)",
|
||||||
"/sign-up(.*)",
|
"/sign-up(.*)",
|
||||||
"/api/webhooks(.*)",
|
"/api/webhooks(.*)",
|
||||||
|
"/api/attendance(.*)",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Define routes that require authentication
|
// Define routes that require authentication
|
||||||
@ -16,7 +17,6 @@ const isProtectedRoute = createRouteMatcher([
|
|||||||
"/api/users(.*)",
|
"/api/users(.*)",
|
||||||
"/api/profile(.*)",
|
"/api/profile(.*)",
|
||||||
"/api/payments(.*)",
|
"/api/payments(.*)",
|
||||||
"/api/attendance(.*)",
|
|
||||||
"/api/notifications(.*)",
|
"/api/notifications(.*)",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Platform } from "react-native";
|
import { API_BASE_URL } from "../config/api";
|
||||||
|
|
||||||
const API_BASE_URL = Platform.select({
|
|
||||||
android: "http://10.0.2.2:3000", // Android emulator maps this to host's localhost
|
|
||||||
ios: "http://localhost:3000",
|
|
||||||
default: process.env.EXPO_PUBLIC_API_URL || "http://localhost:3000",
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface FitnessProfile {
|
export interface FitnessProfile {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@ -1,28 +1,244 @@
|
|||||||
import { View, Text, StyleSheet } from 'react-native'
|
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, ScrollView, Alert } from 'react-native'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '@clerk/clerk-expo'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { API_BASE_URL, API_ENDPOINTS } from '../../config/api'
|
||||||
|
|
||||||
|
interface Attendance {
|
||||||
|
id: string
|
||||||
|
checkInTime: string
|
||||||
|
checkOutTime?: string
|
||||||
|
type: string
|
||||||
|
notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function AttendanceScreen() {
|
export default function AttendanceScreen() {
|
||||||
|
const { getToken, userId } = useAuth()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [activeCheckIn, setActiveCheckIn] = useState<Attendance | null>(null)
|
||||||
|
const [history, setHistory] = useState<Attendance[]>([])
|
||||||
|
|
||||||
|
const fetchAttendance = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const token = await getToken()
|
||||||
|
const url = `${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.HISTORY}`
|
||||||
|
console.log('Fetching attendance from:', url)
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: Attendance[] = response.data
|
||||||
|
setHistory(data)
|
||||||
|
|
||||||
|
// Check if there's an active check-in (latest one has no checkOutTime)
|
||||||
|
if (data.length > 0 && !data[0].checkOutTime) {
|
||||||
|
setActiveCheckIn(data[0])
|
||||||
|
} else {
|
||||||
|
setActiveCheckIn(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching attendance:', error)
|
||||||
|
Alert.alert('Error', 'Failed to load attendance data')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttendance()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCheckIn = async () => {
|
||||||
|
try {
|
||||||
|
const token = await getToken()
|
||||||
|
await axios.post(
|
||||||
|
`${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.CHECK_IN}`,
|
||||||
|
{ type: 'gym' },
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
)
|
||||||
|
fetchAttendance()
|
||||||
|
Alert.alert('Success', 'Checked in successfully!')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Check-in error:', error)
|
||||||
|
Alert.alert('Error', error.response?.data || 'Failed to check in')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheckOut = async () => {
|
||||||
|
try {
|
||||||
|
const token = await getToken()
|
||||||
|
await axios.post(
|
||||||
|
`${API_BASE_URL}${API_ENDPOINTS.ATTENDANCE.CHECK_OUT}`,
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } }
|
||||||
|
)
|
||||||
|
fetchAttendance()
|
||||||
|
Alert.alert('Success', 'Checked out successfully!')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Check-out error:', error)
|
||||||
|
Alert.alert('Error', error.response?.data || 'Failed to check out')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !history.length) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<ActivityIndicator size="large" color="#000" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.title}>Attendance</Text>
|
<Text style={styles.title}>Attendance</Text>
|
||||||
<Text style={styles.subtitle}>Track your gym visits</Text>
|
<Text style={styles.subtitle}>Track your gym visits</Text>
|
||||||
</View>
|
|
||||||
|
<View style={styles.actionContainer}>
|
||||||
|
{activeCheckIn ? (
|
||||||
|
<View style={styles.activeCard}>
|
||||||
|
<Text style={styles.activeText}>Currently Checked In</Text>
|
||||||
|
<Text style={styles.timeText}>
|
||||||
|
Since {new Date(activeCheckIn.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.checkOutButton} onPress={handleCheckOut}>
|
||||||
|
<Text style={styles.buttonText}>Check Out</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity style={styles.checkInButton} onPress={handleCheckIn}>
|
||||||
|
<Text style={styles.buttonText}>Check In</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>Recent History</Text>
|
||||||
|
{history.map((item) => (
|
||||||
|
<View key={item.id} style={styles.historyItem}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.dateText}>
|
||||||
|
{new Date(item.checkInTime).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.typeText}>{item.type.toUpperCase()}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.timeContainer}>
|
||||||
|
<Text style={styles.historyTime}>
|
||||||
|
In: {new Date(item.checkInTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Text>
|
||||||
|
{item.checkOutTime && (
|
||||||
|
<Text style={styles.historyTime}>
|
||||||
|
Out: {new Date(item.checkOutTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: '#f5f5f5',
|
||||||
},
|
},
|
||||||
|
content: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
centered: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 24,
|
fontSize: 28,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
marginBottom: 8,
|
marginBottom: 4,
|
||||||
|
color: '#1a1a1a',
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: '#666',
|
color: '#666',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
actionContainer: {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
checkInButton: {
|
||||||
|
backgroundColor: '#000',
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
checkOutButton: {
|
||||||
|
backgroundColor: '#ff3b30',
|
||||||
|
paddingVertical: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
activeCard: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e0e0e0',
|
||||||
|
},
|
||||||
|
activeText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#2e7d32',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
timeText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 16,
|
||||||
|
color: '#1a1a1a',
|
||||||
|
},
|
||||||
|
historyItem: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
dateText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#1a1a1a',
|
||||||
|
},
|
||||||
|
typeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
timeContainer: {
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
historyTime: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#444',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -1,5 +1,5 @@
|
|||||||
export const API_BASE_URL = __DEV__
|
export const API_BASE_URL = __DEV__
|
||||||
? 'http://192.168.1.24:3000'
|
? 'https://5cb23f31d8c1.ngrok-free.app'
|
||||||
: 'https://your-production-url.com'
|
: 'https://your-production-url.com'
|
||||||
|
|
||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
@ -12,4 +12,9 @@ export const API_ENDPOINTS = {
|
|||||||
},
|
},
|
||||||
CLIENTS: '/api/clients',
|
CLIENTS: '/api/clients',
|
||||||
USERS: '/api/users',
|
USERS: '/api/users',
|
||||||
|
ATTENDANCE: {
|
||||||
|
CHECK_IN: '/api/attendance/check-in',
|
||||||
|
CHECK_OUT: '/api/attendance/check-out',
|
||||||
|
HISTORY: '/api/attendance/history',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@ -8,7 +8,7 @@
|
|||||||
"lib": [
|
"lib": [
|
||||||
"es2017"
|
"es2017"
|
||||||
],
|
],
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user