From 13968dd6fe28a7eb9adbcdf5e91e1b38d52cc710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ben=20B=C3=B6ckmann?= Date: Fri, 25 Oct 2024 14:53:39 +0300 Subject: [PATCH] feat: added bookings table and booking creation form --- package.json | 3 +- src/app/company/dashboard/bookings/page.tsx | 32 +- src/app/company/dashboard/layout.tsx | 17 +- src/app/company/dashboard/settings/page.tsx | 95 +++ src/app/company/dashboard/users/page.tsx | 8 +- .../dashboard/company/BookingsTable.tsx | 717 ++++++++++++++++++ .../dashboard/{ => company}/UsersTable.tsx | 0 src/components/ui/command.tsx | 134 ++++ src/components/ui/dialog.tsx | 2 +- src/components/ui/popover.tsx | 2 +- src/components/ui/select.tsx | 2 +- src/components/ui/textarea.tsx | 21 + src/lib/utils.ts | 34 + src/store/features/collectionSlice.ts | 9 +- 14 files changed, 1037 insertions(+), 39 deletions(-) create mode 100644 src/app/company/dashboard/settings/page.tsx create mode 100644 src/components/dashboard/company/BookingsTable.tsx rename src/components/dashboard/{ => company}/UsersTable.tsx (100%) create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/textarea.tsx diff --git a/package.json b/package.json index bfbe8fc..c08d804 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", - "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.1.1", @@ -67,6 +67,7 @@ "class-variance-authority": "^0.7.0", "classnames": "^2.5.1", "clsx": "^2.1.1", + "cmdk": "1.0.0", "date-fns": "^3.6.0", "eslint-plugin-react": "^7.34.1", "eslint-plugin-tailwindcss": "^3.15.1", diff --git a/src/app/company/dashboard/bookings/page.tsx b/src/app/company/dashboard/bookings/page.tsx index a3363c5..c6bde43 100644 --- a/src/app/company/dashboard/bookings/page.tsx +++ b/src/app/company/dashboard/bookings/page.tsx @@ -1,5 +1,4 @@ "use client"; -import { Input } from "@/components/ui/input"; import { DropdownMenu, DropdownMenuContent, @@ -16,9 +15,10 @@ import { useRouter } from "next/navigation"; import { deleteToken } from "@/lib/authActions"; import Loader from "@/components/layout/Loader"; import { useCompany } from "@/components/dashboard/CompanyContext"; +import { BookingsTable } from "@/components/dashboard/company/BookingsTable"; export default function Page() { - const { user, loading, companyLoading, company } = useCompany(); + const { user, loading, companyLoading } = useCompany(); const router = useRouter(); const logout = async () => { @@ -36,11 +36,8 @@ export default function Page() { return (
-

- {company?.getCompany.name} (ID: {company?.getCompany.id}) -

+

Company Bookings

-
-
-
-
-
- {extractNameInitials(company?.getCompany.name)} -
-
-

{company?.getCompany.name}

-
-
-
-

- {company?.getCompany.description !== "" ? ( - company?.getCompany.description - ) : ( - This company has not provided a description - )} -

+
+
); diff --git a/src/app/company/dashboard/layout.tsx b/src/app/company/dashboard/layout.tsx index 648b11c..852cb7c 100644 --- a/src/app/company/dashboard/layout.tsx +++ b/src/app/company/dashboard/layout.tsx @@ -7,12 +7,12 @@ import { usePathname } from "next/navigation"; import { cn, extractNameInitials } from "@/lib/utils"; import Loader from "@/components/layout/Loader"; import { CompanyProvider, useCompany } from "@/components/dashboard/CompanyContext"; -import { BriefcaseBusiness, Users } from "lucide-react"; +import { BriefcaseBusiness, Settings, Users } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; function DashboardContent({ children }: { children: React.ReactNode }) { const { companyLoading, loading, company } = useCompany(); - const [active, setActive] = useState<"dashboard" | "bookings" | "users" | "members">("dashboard"); + const [active, setActive] = useState<"dashboard" | "bookings" | "users" | "members" | "settings">("dashboard"); const pathname = usePathname(); useEffect(() => { @@ -23,6 +23,8 @@ function DashboardContent({ children }: { children: React.ReactNode }) { setActive("users"); } else if (pathname.includes("/company/dashboard/members")) { setActive("members"); + } else if (pathname.includes("/company/dashboard/settings")) { + setActive("settings"); } else { setActive("dashboard"); } @@ -96,6 +98,17 @@ function DashboardContent({ children }: { children: React.ReactNode }) { Members + + +
diff --git a/src/app/company/dashboard/settings/page.tsx b/src/app/company/dashboard/settings/page.tsx new file mode 100644 index 0000000..11f0935 --- /dev/null +++ b/src/app/company/dashboard/settings/page.tsx @@ -0,0 +1,95 @@ +"use client"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { extractNameInitials } from "@/lib/utils"; +import React from "react"; +import { useRouter } from "next/navigation"; +import { deleteToken } from "@/lib/authActions"; +import Loader from "@/components/layout/Loader"; +import { useCompany } from "@/components/dashboard/CompanyContext"; + +export default function Page() { + const { user, loading, companyLoading, company } = useCompany(); + const router = useRouter(); + + const logout = async () => { + try { + await deleteToken(); + window.location.reload(); + } catch (logoutError) { + console.error("Logout failed", logoutError); + throw logoutError; + } + }; + + if (loading || companyLoading) return ; + + return ( +
+
+

Company Settings

+
+ + + + + + +
+

{user?.name}

+

{user?.email}

+
+
+ + { + router.push("/dashboard/settings"); + }}> + Settings + + + + Log out + +
+
+
+
+ +
+
+
+
+ {extractNameInitials(company?.getCompany.name)} +
+
+

{company?.getCompany.name}

+
+
+
+

+ {company?.getCompany.description !== "" ? ( + company?.getCompany.description + ) : ( + This company has not provided a description + )} +

+
+
+ ); +} diff --git a/src/app/company/dashboard/users/page.tsx b/src/app/company/dashboard/users/page.tsx index fc32624..a9903ac 100644 --- a/src/app/company/dashboard/users/page.tsx +++ b/src/app/company/dashboard/users/page.tsx @@ -15,10 +15,10 @@ import { useRouter } from "next/navigation"; import { deleteToken } from "@/lib/authActions"; import Loader from "@/components/layout/Loader"; import { useCompany } from "@/components/dashboard/CompanyContext"; -import { UsersTable } from "@/components/dashboard/UsersTable"; +import { UsersTable } from "@/components/dashboard/company/UsersTable"; export default function Page() { - const { user, loading, companyLoading, company } = useCompany(); + const { user, loading, companyLoading } = useCompany(); const router = useRouter(); const logout = async () => { @@ -36,9 +36,7 @@ export default function Page() { return (
-

- {company?.getCompany.name} (ID: {company?.getCompany.id}) -

+

Subscribed Clients

diff --git a/src/components/dashboard/company/BookingsTable.tsx b/src/components/dashboard/company/BookingsTable.tsx new file mode 100644 index 0000000..266aca4 --- /dev/null +++ b/src/components/dashboard/company/BookingsTable.tsx @@ -0,0 +1,717 @@ +"use client"; +import * as React from "react"; +import { + type ColumnDef, + type ColumnFiltersState, + type SortingState, + type VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from "@tanstack/react-table"; +import { ArrowUpDown, CalendarIcon, Check, ChevronDown, ChevronsUpDown, Clock, MoreHorizontal } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import type { Appointment, User } from "@/types"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store/store"; +import { calculateAppointmentDuration, cn, formatDateString } from "@/lib/utils"; +import { FaPlus } from "react-icons/fa6"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog"; +import { z } from "zod"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { format, set } from "date-fns"; +import { Textarea } from "@/components/ui/textarea"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { getAccessToken, getAllUsers } from "@/lib/authActions"; + +const bookingFormSchema = z + .object({ + title: z.string().min(2).max(30), + description: z.string().optional(), + from: z.date(), + to: z.date(), + location: z.string(), + client: z.number().optional() // client id + }) + .refine( + (data) => { + // Check if dates are not the same + const sameDateTime = data.from.getTime() === data.to.getTime(); + // Check if 'to' is not earlier than 'from' + const toBeforeFrom = data.to.getTime() < data.from.getTime(); + + return !sameDateTime && !toBeforeFrom; + }, + { + message: "Invalid date range: End time must be after start time", + path: ["to"] // This will show the error under the 'to' field + } + ); + +const columns: Array> = [ + { + id: "select", + header: ({ table }) => ( + { + table.toggleAllPageRowsSelected(value as boolean); + }} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + { + row.toggleSelected(value as boolean); + }} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false + }, + { + accessorKey: "id", + header: ({ column }) => { + return ( + + ); + }, + enableSorting: true, + enableHiding: true + }, + { + accessorKey: "title", + header: "Title", + enableSorting: false + }, + { + accessorKey: "description", + header: "Description", + enableSorting: false + }, + { + accessorKey: "from", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{formatDateString(row.getValue("from"))}
; + } + }, + { + accessorKey: "to", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return
{formatDateString(row.getValue("to"))}
; + } + }, + { + accessorKey: "duration", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return ( +
{calculateAppointmentDuration(row.getValue("from"), row.getValue("to"))}
+ ); + } + }, + { + accessorKey: "status", + header: ({ column }) => { + return ( + + ); + } + }, + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const appointment = row.original; + + return ( + + + + + + Actions + { + void navigator.clipboard.writeText(appointment.id.toString()); + }}> + Copy appointment ID + + {appointment.status === "BOOKED" && appointment.client !== null && ( + { + if (appointment.client?.id !== undefined) + void navigator.clipboard.writeText(appointment.client?.id.toString()); + }}> + Copy booked user ID + + )} + + + ); + } + } +]; + +export function BookingsTable(): React.ReactElement { + const appointments = useSelector((state: RootState) => state.collection.appointments); + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState([]); + const [columnVisibility, setColumnVisibility] = React.useState({ + id: false, + description: false, + to: false + }); + const [rowSelection, setRowSelection] = React.useState({}); + const [clients, setUsers] = React.useState([]); + + React.useEffect(() => { + const fetchUsers = async (): Promise => { + const fetchedUsers = await getAllUsers(await getAccessToken()); + setUsers(fetchedUsers); + }; + void fetchUsers(); + }, []); + + const table = useReactTable({ + data: appointments, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection + } + }); + + const form = useForm>({ + resolver: zodResolver(bookingFormSchema), + defaultValues: { + title: "", + description: "", + from: set(new Date(), { hours: 14, minutes: 0, seconds: 0, milliseconds: 0 }), + to: set(new Date(), { hours: 14, minutes: 30, seconds: 0, milliseconds: 0 }), + location: "" + } + }); + + function onSubmit(values: z.infer) { + console.log(values); + } + + return ( +
+
+ { + table.getColumn("title")?.setFilterValue(event.target.value); + }} + className="max-w-sm" + /> + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + { + column.toggleVisibility(Boolean(value)); + }}> + {column.id} + + ); + })} + + + + + + + + + Create Booking + Create a new Appointment-Slot for clients to book + +
+ + ( + + Title* + + + + + + )} + /> + ( + + Description + +