Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions frontend/app/attendance/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
'use client';

import { useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import Image from 'next/image';
import { useAttendanceSummary } from '@/lib/react-query/hooks/workspace-tracking/useAttendanceSummary';
import { useAttendanceHistory } from '@/lib/react-query/hooks/workspace-tracking/useAttendanceHistory';
import MonthlyActivityChart from '@/components/attendance/MonthlyActivityChart';
import { DatePicker } from '@/components/ui/date-picker';
import { Button } from '@/components/ui/button';
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/table';
import { Pagination } from '@/components/ui/Pagination';
import { apiClient } from '@/lib/apiClient';
import { format } from 'date-fns';
import { saveAs } from 'file-saver';

export default function AttendancePage() {
const router = useRouter();
const searchParams = useSearchParams();
const page = Number(searchParams.get('page')) || 1;
const limit = Number(searchParams.get('limit')) || 10;
const [from, setFrom] = useState<Date | undefined>(
searchParams.get('from') ? new Date(searchParams.get('from')!) : undefined
);
const [to, setTo] = useState<Date | undefined>(
searchParams.get('to') ? new Date(searchParams.get('to')!) : undefined
);

const { data: summaryData, isLoading: summaryIsLoading } = useAttendanceSummary();
const { data: historyData, isLoading: historyIsLoading } = useAttendanceHistory({
page,
limit,
from: from?.toISOString(),
to: to?.toISOString(),
});

const handleDateChange = () => {
const params = new URLSearchParams(searchParams);
if (from) {
params.set('from', from.toISOString());
} else {
params.delete('from');
}
if (to) {
params.set('to', to.toISOString());
} else {
params.delete('to');
}
params.set('page', '1');
router.push(`?${params.toString()}`);
};

const handleExport = async () => {
try {
const response = await apiClient.get('/workspace-tracking/history?format=csv', {
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8;' });
saveAs(blob, `attendance-history-${format(new Date(), 'yyyy-MM-dd')}.csv`);
} catch (error) {
console.error('Failed to export CSV', error);
}
};

const records = historyData?.data.records ?? [];
const totalPages = historyData?.data.totalPages ?? 1;

return (
<div className="space-y-8">
<h1 className="text-2xl font-bold">Attendance</h1>

{/* Summary Cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{summaryIsLoading ? (
<p>Loading summary...</p>
) : (
<>
<div className="rounded-lg border bg-card p-6 text-card-foreground shadow-sm">
<h3 className="text-sm font-medium text-muted-foreground">Total Hours This Month</h3>
<p className="text-2xl font-bold">{summaryData?.data.totalHoursThisMonth ?? 0}</p>
</div>
<div className="rounded-lg border bg-card p-6 text-card-foreground shadow-sm">
<h3 className="text-sm font-medium text-muted-foreground">Days Visited This Month</h3>
<p className="text-2xl font-bold">{summaryData?.data.daysVisitedThisMonth ?? 0}</p>
</div>
<div className="rounded-lg border bg-card p-6 text-card-foreground shadow-sm">
<h3 className="text-sm font-medium text-muted-foreground">Current Streak</h3>
<p className="text-2xl font-bold">{summaryData?.data.currentStreak ?? 0} days</p>
</div>
</>
)}
</div>

{/* Monthly Chart */}
<MonthlyActivityChart data={records} />

{/* History Table */}
<div className="rounded-lg border bg-card p-6 text-card-foreground shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
<h3 className="font-semibold">History</h3>
<div className="flex flex-wrap items-center gap-2">
<DatePicker date={from} setDate={setFrom} />
<DatePicker date={to} setTo={setTo} />
<Button onClick={handleDateChange}>Filter</Button>
<Button variant="outline" onClick={handleExport}>Export</Button>
</div>
</div>
{historyIsLoading ? (
<p>Loading history...</p>
) : records.length === 0 ? (
<div className="text-center py-16">
<Image src="/window.svg" alt="No check-ins yet" width={150} height={150} className="mx-auto mb-4" />
<h3 className="text-lg font-semibold">No check-ins yet</h3>
<p className="text-muted-foreground mb-4">Book a workspace to get started.</p>
<Button asChild>
<a href="/workspaces">Browse Workspaces</a>

Check failure on line 123 in frontend/app/attendance/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend (Next.js)

Do not use an `<a>` element to navigate to `/workspaces/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages
</Button>
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Workspace</TableHead>
<TableHead>Check-in</TableHead>
<TableHead>Check-out</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((record) => (
<TableRow key={record.id}>
<TableCell>{format(new Date(record.checkInTime), 'PPP')}</TableCell>
<TableCell>{record.workspaceName}</TableCell>
<TableCell>{format(new Date(record.checkInTime), 'p')}</TableCell>
<TableCell>{format(new Date(record.checkOutTime), 'p')}</TableCell>
<TableCell>{(record.duration / 3600).toFixed(2)} hours</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pagination currentPage={page} totalPages={totalPages} />
</>
)}
</div>
</div>
);
}
44 changes: 43 additions & 1 deletion frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,48 @@
/* White-label primary colour — overridden at runtime by BrandingProvider */
:root {
--color-primary: #111827;
--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;
}

[data-theme="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%;
}

/* Scrollbar — WebKit */
Expand Down Expand Up @@ -80,4 +122,4 @@
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
}
10 changes: 5 additions & 5 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import Providers from "@/providers/Providers";
import "./globals.css";
import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider";
import { ThemeManager } from "@/components/ThemeManager";

const geistSans = Geist({
variable: "--font-geist-sans",
Expand Down Expand Up @@ -114,13 +114,13 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-50`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ThemeManager>
<Providers>{children}</Providers>
<Toaster richColors position="top-right" />
</ThemeProvider>
</ThemeManager>
</body>
</html>
);
}
}
16 changes: 16 additions & 0 deletions frontend/components/ThemeManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use client';

import { useEffect } from 'react';
import { useThemeStore } from '@/lib/store/themeStore';

export function ThemeManager({ children }: { children: React.ReactNode }) {
const { theme } = useThemeStore();

useEffect(() => {
const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
const resolvedTheme = theme === 'system' ? (isDarkMode ? 'dark' : 'light') : theme;
document.documentElement.setAttribute('data-theme', resolvedTheme);
}, [theme]);

return <>{children}</>;
}
88 changes: 88 additions & 0 deletions frontend/components/attendance/MonthlyActivityChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { AttendanceRecord } from "@/lib/react-query/hooks/workspace-tracking/useAttendanceHistory";

interface MonthlyActivityChartProps {
data: AttendanceRecord[];
}

export default function MonthlyActivityChart({ data }: MonthlyActivityChartProps) {
const processDataForChart = (records: AttendanceRecord[]) => {
const daysInMonth = new Date(
new Date().getFullYear(),
new Date().getMonth() + 1,
0
).getDate();
const chartData = Array.from({ length: daysInMonth }, (_, i) => ({
day: i + 1,
hours: 0,
}));

records.forEach((record) => {
const checkInDate = new Date(record.checkInTime);
const dayOfMonth = checkInDate.getDate();
chartData[dayOfMonth - 1].hours += record.duration / 3600; // Assuming duration is in seconds
});

return chartData;
};

const chartData = processDataForChart(data);

if (!data.length) {
return (
<div className="rounded-lg border bg-card p-6 text-card-foreground shadow-sm">
<h3 className="font-semibold">Monthly Activity</h3>
<div className="h-80 flex items-center justify-center">
<p className="text-muted-foreground">No check-ins yet — book a workspace to get started</p>
</div>
</div>
);
}

return (
<div className="rounded-lg border bg-card p-6 text-card-foreground shadow-sm">
<h3 className="font-semibold mb-4">Monthly Activity</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="day"
axisLine={false}
tickLine={false}
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: 12 }}
allowDecimals={false}
unit="h"
/>
<Tooltip
contentStyle={{
borderRadius: "var(--radius)",
border: "1px solid hsl(var(--border))",
background: "hsl(var(--card))",
}}
labelStyle={{ color: "hsl(var(--foreground))" }}
itemStyle={{ color: "hsl(var(--foreground))" }}
formatter={(value: number) => [\`\${value.toFixed(2)} hours\`, "Duration"]}
/>
<Bar dataKey="hours" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}
3 changes: 2 additions & 1 deletion frontend/components/dashboard/DashboardSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
Building2, LayoutDashboard, User, Settings, LogOut, Users, Mail,
Menu, X, BookOpen, FileText, BriefcaseBusiness, LogIn, Bell,
BarChart3, CreditCard, Boxes, Calendar, MapPin, Wrench, Lock,
MessageSquare, Palette,
MessageSquare, Palette, Clock,
} from "lucide-react";
import { useState } from "react";
import { useAuthState, useAuthActions } from "@/lib/store/authStore";
Expand All @@ -20,6 +20,7 @@ const navItems = [
{ label: "Resources", href: "/resources", icon: Boxes },
{ label: "My Bookings", href: "/bookings", icon: BookOpen },
{ label: "Check In / Out", href: "/check-in", icon: LogIn },
{ label: "Attendance", href: "/attendance", icon: Clock },
{ label: "Messages", href: "/messages", icon: MessageSquare },
{ label: "Notifications", href: "/notifications", icon: Bell },
{ label: "Invoices", href: "/invoices", icon: FileText },
Expand Down
Loading
Loading