Skip to content

Commit b12659c

Browse files
committed
feat: Introduce staff leave management with application and history pages, update staff navigation, and add an attendance backfill script.
1 parent e775eb7 commit b12659c

File tree

7 files changed

+773
-46
lines changed

7 files changed

+773
-46
lines changed

app/staff/history/page.tsx

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
'use client'
2+
3+
import { useEffect, useState } from "react"
4+
import { createClient } from "@/lib/supabase/client"
5+
import { useAuth } from "@/lib/hooks/useAuth"
6+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
7+
import {
8+
Calendar,
9+
Clock,
10+
CheckCircle2,
11+
XCircle,
12+
Filter,
13+
ArrowDownUp,
14+
Download,
15+
History as HistoryIcon
16+
} from "lucide-react"
17+
import { Button } from "@/components/ui/button"
18+
19+
type AttendanceRecord = {
20+
id: string
21+
user_id: string
22+
check_in: string
23+
check_out: string | null
24+
total_hours: number | null
25+
status?: string // 'Complete' | 'Pending'
26+
created_at: string
27+
}
28+
29+
export default function StaffHistoryPage() {
30+
const { user, loading: authLoading } = useAuth()
31+
const [history, setHistory] = useState<AttendanceRecord[]>([])
32+
const [loading, setLoading] = useState(true)
33+
const [stats, setStats] = useState({
34+
totalHours: 0,
35+
daysPresent: 0,
36+
averageHours: 0
37+
})
38+
39+
const supabase = createClient()
40+
41+
useEffect(() => {
42+
if (!user) return
43+
44+
const fetchData = async () => {
45+
try {
46+
const { data, error } = await supabase
47+
.from("attendance_logs")
48+
.select("*")
49+
.eq("user_id", user.id)
50+
.order("check_in", { ascending: false })
51+
52+
if (error) throw error
53+
54+
const records = data || []
55+
setHistory(records)
56+
57+
// Calculate stats
58+
const totalHours = records.reduce((acc, curr) => acc + (curr.total_hours || 0), 0)
59+
const daysPresent = records.length
60+
const averageHours = daysPresent > 0 ? totalHours / daysPresent : 0
61+
62+
setStats({
63+
totalHours,
64+
daysPresent,
65+
averageHours
66+
})
67+
68+
} catch (err) {
69+
console.error("Error fetching history:", err)
70+
} finally {
71+
setLoading(false)
72+
}
73+
}
74+
75+
fetchData()
76+
}, [user, supabase])
77+
78+
if (authLoading || (loading && !history.length)) {
79+
return (
80+
<div className="flex items-center justify-center min-h-[60vh]">
81+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
82+
</div>
83+
)
84+
}
85+
86+
return (
87+
<div className="max-w-7xl mx-auto space-y-8 pb-10 px-4 md:px-0">
88+
{/* Header */}
89+
<header className="flex flex-col md:flex-row md:items-center justify-between gap-4">
90+
<div>
91+
<h1 className="text-3xl font-bold tracking-tight text-white mb-2">
92+
Attendance <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-400">History</span>
93+
</h1>
94+
<p className="text-zinc-400">
95+
View and track your past attendance records.
96+
</p>
97+
</div>
98+
{/* <div className="flex gap-2">
99+
<Button variant="outline" className="border-zinc-800 bg-zinc-900/50 hover:bg-zinc-800 text-zinc-300">
100+
<Filter className="mr-2 h-4 w-4" /> Filter
101+
</Button>
102+
<Button variant="outline" className="border-zinc-800 bg-zinc-900/50 hover:bg-zinc-800 text-zinc-300">
103+
<Download className="mr-2 h-4 w-4" /> Export
104+
</Button>
105+
</div> */}
106+
</header>
107+
108+
{/* Stats Overview */}
109+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
110+
<Card className="bg-zinc-900/50 border-zinc-800 backdrop-blur-sm">
111+
<CardContent className="p-6 flex items-center gap-4">
112+
<div className="p-3 rounded-full bg-blue-500/10 border border-blue-500/20">
113+
<Clock className="w-6 h-6 text-blue-400" />
114+
</div>
115+
<div>
116+
<p className="text-sm font-medium text-zinc-400">Total Hours</p>
117+
<h3 className="text-2xl font-bold text-white">{stats.totalHours.toFixed(2)}h</h3>
118+
</div>
119+
</CardContent>
120+
</Card>
121+
122+
<Card className="bg-zinc-900/50 border-zinc-800 backdrop-blur-sm">
123+
<CardContent className="p-6 flex items-center gap-4">
124+
<div className="p-3 rounded-full bg-green-500/10 border border-green-500/20">
125+
<Calendar className="w-6 h-6 text-green-400" />
126+
</div>
127+
<div>
128+
<p className="text-sm font-medium text-zinc-400">Days Present</p>
129+
<h3 className="text-2xl font-bold text-white">{stats.daysPresent}</h3>
130+
</div>
131+
</CardContent>
132+
</Card>
133+
134+
<Card className="bg-zinc-900/50 border-zinc-800 backdrop-blur-sm">
135+
<CardContent className="p-6 flex items-center gap-4">
136+
<div className="p-3 rounded-full bg-purple-500/10 border border-purple-500/20">
137+
<HistoryIcon className="w-6 h-6 text-purple-400" />
138+
</div>
139+
<div>
140+
<p className="text-sm font-medium text-zinc-400">Avg. Daily Hours</p>
141+
<h3 className="text-2xl font-bold text-white">{stats.averageHours.toFixed(2)}h</h3>
142+
</div>
143+
</CardContent>
144+
</Card>
145+
</div>
146+
147+
{/* History Table */}
148+
<Card className="bg-zinc-900/50 border-zinc-800 backdrop-blur-sm overflow-hidden">
149+
<CardHeader className="border-b border-zinc-800/50 bg-zinc-900/50">
150+
<CardTitle className="text-lg font-medium text-white flex items-center gap-2">
151+
<HistoryIcon className="w-5 h-5 text-blue-400" />
152+
Detailed Logs
153+
</CardTitle>
154+
</CardHeader>
155+
<div className="overflow-x-auto">
156+
<table className="w-full text-left text-sm">
157+
<thead className="bg-zinc-900/80 text-zinc-400 font-medium">
158+
<tr className="border-b border-zinc-800">
159+
<th className="px-6 py-4">Date</th>
160+
<th className="px-6 py-4">Check In</th>
161+
<th className="px-6 py-4">Check Out</th>
162+
<th className="px-6 py-4">Duration</th>
163+
<th className="px-6 py-4">Status</th>
164+
</tr>
165+
</thead>
166+
<tbody className="divide-y divide-zinc-800">
167+
{history.length === 0 ? (
168+
<tr>
169+
<td colSpan={5} className="px-6 py-12 text-center text-zinc-500">
170+
No attendance records found.
171+
</td>
172+
</tr>
173+
) : (
174+
history.map((record) => {
175+
const date = new Date(record.check_in).toLocaleDateString(undefined, {
176+
weekday: 'short',
177+
year: 'numeric',
178+
month: 'short',
179+
day: 'numeric'
180+
})
181+
const checkInTime = new Date(record.check_in).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
182+
const checkOutTime = record.check_out
183+
? new Date(record.check_out).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
184+
: '-'
185+
186+
const isComplete = !!record.check_out
187+
188+
return (
189+
<tr key={record.id} className="group hover:bg-zinc-800/30 transition-colors">
190+
<td className="px-6 py-4 text-zinc-300 font-medium">{date}</td>
191+
<td className="px-6 py-4 text-zinc-400">{checkInTime}</td>
192+
<td className="px-6 py-4 text-zinc-400">{checkOutTime}</td>
193+
<td className="px-6 py-4">
194+
<span className={`px-2.5 py-1 rounded font-mono text-xs font-medium ${record.total_hours && record.total_hours >= 8
195+
? "bg-green-500/10 text-green-400 border border-green-500/20"
196+
: "bg-blue-500/10 text-blue-400 border border-blue-500/20"
197+
}`}>
198+
{record.total_hours ? `${record.total_hours.toFixed(2)}h` : '-'}
199+
</span>
200+
</td>
201+
<td className="px-6 py-4">
202+
<div className={`flex items-center gap-1.5 text-xs font-medium ${isComplete ? "text-green-400" : "text-yellow-400"
203+
}`}>
204+
{isComplete ? (
205+
<>
206+
<CheckCircle2 className="w-4 h-4" />
207+
Completed
208+
</>
209+
) : (
210+
<>
211+
<Clock className="w-4 h-4" />
212+
Active
213+
</>
214+
)}
215+
</div>
216+
</td>
217+
</tr>
218+
)
219+
})
220+
)}
221+
</tbody>
222+
</table>
223+
</div>
224+
</Card>
225+
</div>
226+
)
227+
}

app/staff/layout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ const sidebarItems: SidebarGroupType[] = [
4747
url: "/staff/tasks",
4848
icon: ClipboardList,
4949
},
50+
{
51+
title: "My Leaves",
52+
url: "/staff/leaves",
53+
icon: CalendarDays,
54+
},
5055
],
5156
},
5257
{

app/staff/leaves/page.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"use client"
2+
3+
import { useEffect, useState } from "react"
4+
import { createClient } from "@/lib/supabase/client"
5+
import { useAuth } from "@/lib/hooks/useAuth"
6+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
7+
import {
8+
Calendar,
9+
CalendarClock,
10+
CheckCircle2,
11+
XCircle,
12+
Clock
13+
} from "lucide-react"
14+
import { Badge } from "@/components/ui/badge"
15+
import { format, differenceInBusinessDays, parseISO } from "date-fns"
16+
17+
type LeaveRequest = {
18+
id: string
19+
leave_type: string
20+
start_date: string
21+
end_date: string
22+
reason: string
23+
status: 'pending' | 'approved' | 'rejected'
24+
created_at: string
25+
}
26+
27+
export default function MyLeavesPage() {
28+
const { user, loading: authLoading } = useAuth()
29+
const [leaves, setLeaves] = useState<LeaveRequest[]>([])
30+
const [loading, setLoading] = useState(true)
31+
const supabase = createClient()
32+
33+
useEffect(() => {
34+
if (!user) return
35+
36+
const fetchLeaves = async () => {
37+
try {
38+
const { data, error } = await supabase
39+
.from("leave_requests")
40+
.select("*")
41+
.eq("user_id", user.id)
42+
.order("created_at", { ascending: false })
43+
44+
if (error) throw error
45+
setLeaves(data || [])
46+
} catch (err) {
47+
console.error("Error fetching leave requests:", err)
48+
} finally {
49+
setLoading(false)
50+
}
51+
}
52+
53+
fetchLeaves()
54+
}, [user, supabase])
55+
56+
if (authLoading || loading) {
57+
return (
58+
<div className="flex items-center justify-center min-h-[60vh]">
59+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
60+
</div>
61+
)
62+
}
63+
64+
return (
65+
<div className="max-w-7xl mx-auto space-y-8 pb-10 px-4 md:px-0">
66+
<header>
67+
<h1 className="text-3xl font-bold tracking-tight text-white mb-2">
68+
My Leave <span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-400">Requests</span>
69+
</h1>
70+
<p className="text-zinc-400">
71+
Track the status of your leave applications.
72+
</p>
73+
</header>
74+
75+
<Card className="bg-zinc-900/50 border-zinc-800 backdrop-blur-sm overflow-hidden">
76+
<CardHeader className="border-b border-zinc-800/50 bg-zinc-900/50">
77+
<CardTitle className="text-lg font-medium text-white flex items-center gap-2">
78+
<CalendarClock className="w-5 h-5 text-blue-400" />
79+
Application History
80+
</CardTitle>
81+
</CardHeader>
82+
<div className="overflow-x-auto">
83+
<table className="w-full text-left text-sm">
84+
<thead className="bg-zinc-900/80 text-zinc-400 font-medium">
85+
<tr className="border-b border-zinc-800">
86+
<th className="px-6 py-4">Applied On</th>
87+
<th className="px-6 py-4">Leave Type</th>
88+
<th className="px-6 py-4">Duration</th>
89+
<th className="px-6 py-4">Status</th>
90+
<th className="px-6 py-4">Reason</th>
91+
</tr>
92+
</thead>
93+
<tbody className="divide-y divide-zinc-800">
94+
{leaves.length === 0 ? (
95+
<tr>
96+
<td colSpan={5} className="px-6 py-12 text-center text-zinc-500">
97+
No leave requests found. start by applying for one!
98+
</td>
99+
</tr>
100+
) : (
101+
leaves.map((leave) => {
102+
const appliedDate = format(parseISO(leave.created_at), "MMM d, yyyy")
103+
const startDate = format(parseISO(leave.start_date), "MMM d")
104+
const endDate = format(parseISO(leave.end_date), "MMM d, yyyy")
105+
const days = differenceInBusinessDays(parseISO(leave.end_date), parseISO(leave.start_date)) + 1
106+
107+
return (
108+
<tr key={leave.id} className="group hover:bg-zinc-800/30 transition-colors">
109+
<td className="px-6 py-4 text-zinc-400">{appliedDate}</td>
110+
<td className="px-6 py-4">
111+
<Badge variant="outline" className="bg-zinc-800/50 border-zinc-700 text-zinc-300 pointer-events-none">
112+
{leave.leave_type}
113+
</Badge>
114+
</td>
115+
<td className="px-6 py-4 text-zinc-300">
116+
<div className="flex flex-col">
117+
<span className="font-medium">{days} Day{days > 1 ? 's' : ''}</span>
118+
<span className="text-xs text-zinc-500">{startDate} - {endDate}</span>
119+
</div>
120+
</td>
121+
<td className="px-6 py-4">
122+
<div className={`flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full w-fit border ${leave.status === 'approved'
123+
? "bg-green-500/30 text-green-300 border-green-500/50"
124+
: leave.status === 'rejected'
125+
? "bg-red-500/30 text-red-300 border-red-500/50"
126+
: "bg-yellow-500/30 text-yellow-300 border-yellow-500/50"
127+
}`}>
128+
{leave.status === 'approved' && <CheckCircle2 className="w-3.5 h-3.5" />}
129+
{leave.status === 'rejected' && <XCircle className="w-3.5 h-3.5" />}
130+
{leave.status === 'pending' && <Clock className="w-3.5 h-3.5" />}
131+
{leave.status.charAt(0).toUpperCase() + leave.status.slice(1)}
132+
</div>
133+
</td>
134+
<td className="px-6 py-4 text-zinc-500 max-w-xs truncate" title={leave.reason}>
135+
{leave.reason}
136+
</td>
137+
</tr>
138+
)
139+
})
140+
)}
141+
</tbody>
142+
</table>
143+
</div>
144+
</Card>
145+
</div>
146+
)
147+
}

0 commit comments

Comments
 (0)