diff --git a/frontend/app/attendance/page.tsx b/frontend/app/attendance/page.tsx new file mode 100644 index 0000000..1e4114d --- /dev/null +++ b/frontend/app/attendance/page.tsx @@ -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( + searchParams.get('from') ? new Date(searchParams.get('from')!) : undefined + ); + const [to, setTo] = useState( + 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 ( +
+

Attendance

+ + {/* Summary Cards */} +
+ {summaryIsLoading ? ( +

Loading summary...

+ ) : ( + <> +
+

Total Hours This Month

+

{summaryData?.data.totalHoursThisMonth ?? 0}

+
+
+

Days Visited This Month

+

{summaryData?.data.daysVisitedThisMonth ?? 0}

+
+
+

Current Streak

+

{summaryData?.data.currentStreak ?? 0} days

+
+ + )} +
+ + {/* Monthly Chart */} + + + {/* History Table */} +
+
+

History

+
+ + + + +
+
+ {historyIsLoading ? ( +

Loading history...

+ ) : records.length === 0 ? ( +
+ No check-ins yet +

No check-ins yet

+

Book a workspace to get started.

+ +
+ ) : ( + <> + + + + Date + Workspace + Check-in + Check-out + Duration + + + + {records.map((record) => ( + + {format(new Date(record.checkInTime), 'PPP')} + {record.workspaceName} + {format(new Date(record.checkInTime), 'p')} + {format(new Date(record.checkOutTime), 'p')} + {(record.duration / 3600).toFixed(2)} hours + + ))} + +
+ + + )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/app/globals.css b/frontend/app/globals.css index e69f6cb..46afe92 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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 */ @@ -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; -} +} \ No newline at end of file diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 360f040..3425afe 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -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", @@ -114,13 +114,13 @@ export default function RootLayout({ return ( - + {children} - + ); -} +} \ No newline at end of file diff --git a/frontend/components/ThemeManager.tsx b/frontend/components/ThemeManager.tsx new file mode 100644 index 0000000..44eaeb2 --- /dev/null +++ b/frontend/components/ThemeManager.tsx @@ -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}; +} \ No newline at end of file diff --git a/frontend/components/attendance/MonthlyActivityChart.tsx b/frontend/components/attendance/MonthlyActivityChart.tsx new file mode 100644 index 0000000..88781df --- /dev/null +++ b/frontend/components/attendance/MonthlyActivityChart.tsx @@ -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 ( +
+

Monthly Activity

+
+

No check-ins yet — book a workspace to get started

+
+
+ ); + } + + return ( +
+

Monthly Activity

+
+ + + + + + [\`\${value.toFixed(2)} hours\`, "Duration"]} + /> + + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/dashboard/DashboardSidebar.tsx b/frontend/components/dashboard/DashboardSidebar.tsx index d3ac567..f4d221d 100644 --- a/frontend/components/dashboard/DashboardSidebar.tsx +++ b/frontend/components/dashboard/DashboardSidebar.tsx @@ -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"; @@ -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 }, diff --git a/frontend/components/ui/Hero.tsx b/frontend/components/ui/Hero.tsx index d9842e3..2a6e51b 100644 --- a/frontend/components/ui/Hero.tsx +++ b/frontend/components/ui/Hero.tsx @@ -2,17 +2,17 @@ import Link from "next/link"; export function Hero() { return ( -
+
{/* Text — left side */}
-

+

Workspace management
that just works.

-

+

One place for bookings, billing, and access control — so you can focus on what matters.

@@ -20,13 +20,13 @@ export function Hero() {
Get started free See how it works @@ -37,13 +37,13 @@ export function Hero() {
{/* Main card */} -
+
-

+

Your workspace

-

+

Acme HQ

@@ -59,11 +59,11 @@ export function Hero() { { label: "Desks booked", value: "84%" }, { label: "Revenue", value: "$12.4k" }, ].map((s) => ( -
-

+

+

{s.value}

-

{s.label}

+

{s.label}

))}
@@ -73,7 +73,7 @@ export function Hero() { {[40, 65, 50, 80, 60, 90, 75].map((h, i) => (
))} @@ -81,13 +81,13 @@ export function Hero() {
{/* Floating accent card */} -
+
+3
-

New members

-

Joined today

+

New members

+

Joined today

@@ -95,4 +95,4 @@ export function Hero() {
); -} +} \ No newline at end of file diff --git a/frontend/components/ui/HowItWorks.tsx b/frontend/components/ui/HowItWorks.tsx index 3ac209c..beb7e2c 100644 --- a/frontend/components/ui/HowItWorks.tsx +++ b/frontend/components/ui/HowItWorks.tsx @@ -18,35 +18,35 @@ const steps = [ export default function HowItWorks() { return ( -
+
-

+

How it works

-

+

Three steps. No onboarding calls, no week-long setup.

{/* connecting line — desktop only */} -
+
{steps.map((s, i) => (
- + {s.number} -

+

{s.title}

-

{s.desc}

+

{s.desc}

))}
); -} +} \ No newline at end of file diff --git a/frontend/components/ui/Input.tsx b/frontend/components/ui/Input.tsx index 2eb8b22..eec0543 100644 --- a/frontend/components/ui/Input.tsx +++ b/frontend/components/ui/Input.tsx @@ -13,24 +13,24 @@ const Input = forwardRef( return (
{icon && ( -
+
{icon}
)} - {error &&

{error}

} + {error &&

{error}

}
); } @@ -38,4 +38,4 @@ const Input = forwardRef( Input.displayName = 'Input'; -export { Input }; +export { Input }; \ No newline at end of file diff --git a/frontend/components/ui/Navbar.tsx b/frontend/components/ui/Navbar.tsx index fe715c4..46df6b2 100644 --- a/frontend/components/ui/Navbar.tsx +++ b/frontend/components/ui/Navbar.tsx @@ -1,12 +1,22 @@ -"use client"; - -import { Building2, Menu, X } from "lucide-react"; -import Image from "next/image"; -import Link from "next/link"; -import { useState } from "react"; -import { useAuthStore } from "@/lib/store/authStore"; -import { LocationSwitcher } from "./LocationSwitcher"; -import { useBranding } from "@/lib/branding/BrandingContext"; +'use client'; + +import { Building2, Menu, X } from 'lucide-react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { useState } from 'react'; +import { useAuthStore } from '@/lib/store/authStore'; +import { LocationSwitcher } from './LocationSwitcher'; +import { useBranding } from '@/lib/branding/BrandingContext'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { ThemeToggle } from './ThemeToggle'; type NavItem = { label: string; @@ -14,9 +24,9 @@ type NavItem = { }; const NAV_ITEMS: NavItem[] = [ - { label: "Features", href: "#features" }, - { label: "How it works", href: "#how-it-works" }, - { label: "Resources", href: "/resources" }, + { label: 'Features', href: '#features' }, + { label: 'How it works', href: '#how-it-works' }, + { label: 'Resources', href: '/resources' }, ]; export function Navbar({ @@ -71,18 +81,33 @@ export function Navbar({
{user ? ( - <> - - {user.name} - - - - + + + + + + {user.name} + + + Profile + + + Sign out + + +
+ Theme + +
+
+
) : ( <> -

{title}

+

{title}

{subtitle && ( -

{subtitle}

+

{subtitle}

)}
); -} +} \ No newline at end of file diff --git a/frontend/components/ui/ThemeToggle.tsx b/frontend/components/ui/ThemeToggle.tsx index cf2bdca..34f9a8d 100644 --- a/frontend/components/ui/ThemeToggle.tsx +++ b/frontend/components/ui/ThemeToggle.tsx @@ -1,23 +1,43 @@ -"use client"; +'use client'; -import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; +import * as React from 'react'; +import { Moon, Sun, Monitor } from 'lucide-react'; +import { useThemeStore } from '@/lib/store/themeStore'; -export default function ThemeToggle() { - const { theme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; - useEffect(() => setMounted(true), []); - - if (!mounted) return null; +export function ThemeToggle() { + const { setTheme } = useThemeStore(); return ( - + + + + + + setTheme('light')}> + + Light + + setTheme('dark')}> + + Dark + + setTheme('system')}> + + System + + + ); } \ No newline at end of file diff --git a/frontend/components/ui/ToggleBar.tsx b/frontend/components/ui/ToggleBar.tsx index 8be6b6e..0704c51 100644 --- a/frontend/components/ui/ToggleBar.tsx +++ b/frontend/components/ui/ToggleBar.tsx @@ -16,7 +16,7 @@ interface ToggleBarProps { export function ToggleBar({ options, value, onChange, className }: ToggleBarProps) { return (
@@ -30,8 +30,8 @@ export function ToggleBar({ options, value, onChange, className }: ToggleBarProp className={cn( 'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2', value === option.id - ? 'bg-[#2563EB] text-white shadow-sm focus:ring-[#3b82f6]' - : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50 focus:ring-gray-400' + ? 'bg-primary text-primary-foreground shadow-sm focus:ring-primary' + : 'text-muted-foreground hover:text-foreground hover:bg-muted/50 focus:ring-ring' )} > {option.icon} @@ -40,4 +40,4 @@ export function ToggleBar({ options, value, onChange, className }: ToggleBarProp ))}
); -} +} \ No newline at end of file diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx new file mode 100644 index 0000000..8ac3957 --- /dev/null +++ b/frontend/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as React from 'react'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; + +import { cn } from '@/lib/utils'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; \ No newline at end of file diff --git a/frontend/components/ui/calendar.tsx b/frontend/components/ui/calendar.tsx new file mode 100644 index 0000000..194c2d0 --- /dev/null +++ b/frontend/components/ui/calendar.tsx @@ -0,0 +1,54 @@ +'use client'; + +import * as React from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { DayPicker } from 'react-day-picker'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) { + return ( + , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ); +} +Calendar.displayName = 'Calendar'; + +export { Calendar }; \ No newline at end of file diff --git a/frontend/components/ui/date-picker.tsx b/frontend/components/ui/date-picker.tsx new file mode 100644 index 0000000..758e8b6 --- /dev/null +++ b/frontend/components/ui/date-picker.tsx @@ -0,0 +1,46 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { Calendar as CalendarIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +interface DatePickerProps { + date: Date | undefined; + setDate: (date: Date | undefined) => void; +} + +export function DatePicker({ date, setDate }: DatePickerProps) { + return ( + + + + + + + + + ) +} \ No newline at end of file diff --git a/frontend/components/ui/dropdown-menu.tsx b/frontend/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..b59f9a8 --- /dev/null +++ b/frontend/components/ui/dropdown-menu.tsx @@ -0,0 +1,201 @@ +'use client'; + +import * as React from 'react'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { Check, ChevronRight, Circle } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; \ No newline at end of file diff --git a/frontend/components/ui/popover.tsx b/frontend/components/ui/popover.tsx new file mode 100644 index 0000000..ec42c03 --- /dev/null +++ b/frontend/components/ui/popover.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } \ No newline at end of file diff --git a/frontend/lib/react-query/hooks/workspace-tracking/useAttendanceHistory.ts b/frontend/lib/react-query/hooks/workspace-tracking/useAttendanceHistory.ts new file mode 100644 index 0000000..54e4e1c --- /dev/null +++ b/frontend/lib/react-query/hooks/workspace-tracking/useAttendanceHistory.ts @@ -0,0 +1,46 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; + +export interface AttendanceRecord { + id: string; + workspaceName: string; + checkInTime: string; + checkOutTime: string; + duration: number; +} + +interface AttendanceHistoryResponse { + success: boolean; + data: { + records: AttendanceRecord[]; + totalPages: number; + currentPage: number; + }; +} + +interface HistoryParams { + page?: number; + limit?: number; + from?: string; + to?: string; +} + +export const useAttendanceHistory = (params: HistoryParams = {}) => { + return useQuery({ + queryKey: queryKeys.workspaceTracking.history(params), + queryFn: () => { + const queryParams = new URLSearchParams(); + if (params.page) queryParams.append("page", String(params.page)); + if (params.limit) queryParams.append("limit", String(params.limit)); + if (params.from) queryParams.append("from", params.from); + if (params.to) queryParams.append("to", params.to); + + return apiClient.get( + `/workspace-tracking/history?${queryParams.toString()}` + ); + }, + }); +}; \ No newline at end of file diff --git a/frontend/lib/react-query/hooks/workspace-tracking/useAttendanceSummary.ts b/frontend/lib/react-query/hooks/workspace-tracking/useAttendanceSummary.ts new file mode 100644 index 0000000..9f460ab --- /dev/null +++ b/frontend/lib/react-query/hooks/workspace-tracking/useAttendanceSummary.ts @@ -0,0 +1,24 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { queryKeys } from "@/lib/react-query/keys/queryKeys"; + +interface AttendanceSummary { + totalHoursThisMonth: number; + daysVisitedThisMonth: number; + currentStreak: number; +} + +interface AttendanceSummaryResponse { + success: boolean; + data: AttendanceSummary; +} + +export const useAttendanceSummary = () => { + return useQuery({ + queryKey: queryKeys.workspaceTracking.summary, + queryFn: () => + apiClient.get("/workspace-tracking/summary"), + }); +}; \ No newline at end of file diff --git a/frontend/lib/react-query/keys/queryKeys.ts b/frontend/lib/react-query/keys/queryKeys.ts index b643f2b..9f96414 100644 --- a/frontend/lib/react-query/keys/queryKeys.ts +++ b/frontend/lib/react-query/keys/queryKeys.ts @@ -32,6 +32,7 @@ export const queryKeys = { active: ["workspace-tracking", "active"] as const, history: (params?: unknown) => ["workspace-tracking", "history", params] as const, + summary: ["workspace-tracking", "summary"] as const, }, dashboard: { member: ["dashboard", "member"] as const, @@ -72,4 +73,4 @@ export const queryKeys = { list: (params?: unknown) => ["admin", "payments", "list", params] as const, }, }, -} as const; +} as const; \ No newline at end of file diff --git a/frontend/lib/store/themeStore.ts b/frontend/lib/store/themeStore.ts new file mode 100644 index 0000000..8fa33b4 --- /dev/null +++ b/frontend/lib/store/themeStore.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +type ThemeState = { + theme: 'light' | 'dark' | 'system'; + setTheme: (theme: 'light' | 'dark' | 'system') => void; +}; + +export const useThemeStore = create()( + persist( + (set) => ({ + theme: 'system', + setTheme: (theme) => set({ theme }), + }), + { + name: 'theme-storage', // name of the item in the storage (must be unique) + } + ) +); \ No newline at end of file diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index bd0c391..04b43bf 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" +import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" - + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..98ac8c4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,82 @@ +{ + "name": "ManageHub", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "date-fns": "^4.4.0", + "file-saver": "^2.0.5", + "react-day-picker": "^10.0.1" + }, + "devDependencies": { + "@types/file-saver": "^2.0.7" + } + }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", + "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec9d2db --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "dependencies": { + "date-fns": "^4.4.0", + "file-saver": "^2.0.5", + "react-day-picker": "^10.0.1" + }, + "devDependencies": { + "@types/file-saver": "^2.0.7" + } +}