From 1d82c18179624d830d87a1979fae014a7b6124dc Mon Sep 17 00:00:00 2001 From: gibbyb Date: Mon, 8 Sep 2025 11:25:11 -0500 Subject: [PATCH] almost done --- convex/statuses.ts | 3 +- src/app/(auth)/signin/page.tsx | 2 +- src/app/(status)/table/layout.tsx | 14 + src/app/(status)/table/page.tsx | 18 ++ src/app/page.tsx | 3 - .../layout/profile/reset-password.tsx | 2 +- .../layout/status/history/index.tsx | 187 ++++++++++++- src/components/layout/status/list/index.tsx | 130 +++++---- src/components/layout/status/table/index.tsx | 255 ++++-------------- src/lib/types.ts | 5 + src/lib/utils.ts | 6 +- 11 files changed, 330 insertions(+), 295 deletions(-) create mode 100644 src/app/(status)/table/layout.tsx create mode 100644 src/app/(status)/table/page.tsx create mode 100644 src/lib/types.ts diff --git a/convex/statuses.ts b/convex/statuses.ts index 9d9c505..189e9a6 100644 --- a/convex/statuses.ts +++ b/convex/statuses.ts @@ -9,8 +9,7 @@ import { import type { Doc, Id } from './_generated/dataModel'; import { paginationOptsValidator } from 'convex/server'; -// NEW: shared ctx type for helpers -type RWCtx = MutationCtx | QueryCtx; +type RWCtx = MutationCtx | QueryCtx type StatusRow = { user: { diff --git a/src/app/(auth)/signin/page.tsx b/src/app/(auth)/signin/page.tsx index 33e3668..4611f98 100644 --- a/src/app/(auth)/signin/page.tsx +++ b/src/app/(auth)/signin/page.tsx @@ -24,7 +24,7 @@ import { TabsTrigger, } from '@/components/ui'; import { toast } from 'sonner'; -import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/utils'; +import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types'; const signInFormSchema = z.object({ email: z.email({ diff --git a/src/app/(status)/table/layout.tsx b/src/app/(status)/table/layout.tsx new file mode 100644 index 0000000..b3c3eaa --- /dev/null +++ b/src/app/(status)/table/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next'; + +export const generateMetadata = (): Metadata => { + return { + title: 'Status Table', + }; +}; + +const SignInLayout = ({ + children, +}: Readonly<{ children: React.ReactNode }>) => { + return
{children}
; +}; +export default SignInLayout; diff --git a/src/app/(status)/table/page.tsx b/src/app/(status)/table/page.tsx new file mode 100644 index 0000000..7265ef3 --- /dev/null +++ b/src/app/(status)/table/page.tsx @@ -0,0 +1,18 @@ +'use server'; +import { preloadQuery } from 'convex/nextjs'; +import { api } from '~/convex/_generated/api'; +import { StatusTable } from '@/components/layout/status'; + +const StatusTablePage = async () => { + const preloadedUser = await preloadQuery(api.auth.getUser); + const preloadedStatuses = await preloadQuery(api.statuses.getCurrentForAll); + return ( +
+ +
+ ); +}; +export default StatusTablePage; diff --git a/src/app/page.tsx b/src/app/page.tsx index 0cd5ba1..30b5864 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,9 +2,6 @@ import { preloadQuery } from 'convex/nextjs'; import { api } from '~/convex/_generated/api'; import { StatusList } from '@/components/layout/status/list'; -import { useConvexAuth, useMutation, useQuery } from 'convex/react'; -import Link from 'next/link'; -import { useAuthActions } from '@convex-dev/auth/react'; const Home = async () => { const preloadedUser = await preloadQuery(api.auth.getUser); diff --git a/src/components/layout/profile/reset-password.tsx b/src/components/layout/profile/reset-password.tsx index 6fb5316..06b8085 100644 --- a/src/components/layout/profile/reset-password.tsx +++ b/src/components/layout/profile/reset-password.tsx @@ -21,7 +21,7 @@ import { SubmitButton, } from '@/components/ui'; import { toast } from 'sonner'; -import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/utils'; +import { PASSWORD_MIN, PASSWORD_MAX, PASSWORD_REGEX } from '@/lib/types'; const formSchema = z .object({ diff --git a/src/components/layout/status/history/index.tsx b/src/components/layout/status/history/index.tsx index d713bed..6f80398 100644 --- a/src/components/layout/status/history/index.tsx +++ b/src/components/layout/status/history/index.tsx @@ -1,10 +1,10 @@ 'use client'; import Image from 'next/image'; -import Link from 'next/link'; -import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react'; +import { useEffect, useMemo, useState } from 'react'; +import { useQuery } from 'convex/react'; import { api } from '~/convex/_generated/api'; -import { type Id } from '~/convex/_generated/dataModel'; -import { useTVMode } from '@/components/providers'; +import type { Id } from '~/convex/_generated/dataModel'; +import { formatDate, formatTime } from '@/lib/utils'; import { DrawerClose, DrawerContent, @@ -13,8 +13,6 @@ import { DrawerTitle, Pagination, PaginationContent, - PaginationLink, - PaginationItem, PaginationNext, PaginationPrevious, ScrollArea, @@ -25,18 +23,183 @@ import { TableHeader, TableRow, Button, + BasedAvatar, } from '@/components/ui'; -import { toast } from 'sonner'; type StatusHistoryProps = { - userId?: Id<'users'>; + user?: typeof api.statuses.getCurrentForAll._returnType[0]['user'], }; -export const StatusHistory = ({ - userId, -}: StatusHistoryProps) => { +const PAGE_SIZE = 25; + +export const StatusHistory = ({ user }: StatusHistoryProps) => { + const [pageIndex, setPageIndex] = useState(0); + // cursor for page N is the continueCursor returned from page N-1 + const [cursors, setCursors] = useState<(string | null)[]>([null]); + + const args = useMemo(() => { + return { + userId: user?.id, + paginationOpts: { + numItems: PAGE_SIZE, + cursor: cursors[pageIndex] ?? null, + }, + }; + }, [user?.id, cursors, pageIndex]); + + const data = useQuery(api.statuses.listHistory, args); + + // Track loading + const isLoading = data === undefined; + + // When a page loads, cache its "next" cursor if we don't have it yet + useEffect(() => { + if (!data) return; + const nextIndex = pageIndex + 1; + setCursors((prev) => { + const copy = [...prev]; + if (copy[nextIndex] === undefined) + copy[nextIndex] = data.continueCursor; + return copy; + }); + }, [data, pageIndex]); + + const canPrev = pageIndex > 0; + const canNext = !!data && data.continueCursor !== null; + + const handlePrev = () => { + if (!canPrev) return; + setPageIndex((p) => Math.max(0, p - 1)); + }; + + const handleNext = () => { + if (!canNext) return; + setPageIndex((p) => p + 1); + }; + + const rows = data?.page ?? []; return ( -
+ + + +
+ {user ? ( + + ) : ( + Tech Tracker Logo + )} +

+ {user ? `${user.name ?? 'Technician'}'s History` : 'All History'} +

+
+
+
+ +
+ + {isLoading ? ( +
+
+
+ ) : rows.length === 0 ? ( +
+

No history found

+
+ ) : ( + + + + Name + Status + Updated By + + Date & Time + + + + + {rows.map((r, idx) => ( + + + {r.user.name ?? 'Technician'} + + +
+ {r.status?.message ?? 'No status'} +
+
+ + {r.status?.updatedBy?.name ?? ''} + + + {r.status + ? `${formatTime(r.status.updatedAt)} · ${ + formatDate(r.status.updatedAt) + }` + : '--:-- · --/--'} + +
+ ))} +
+
+ )} + +
+ + + + + + { + e.preventDefault(); + handlePrev(); + }} + aria-disabled={!canPrev} + className={!canPrev ? 'pointer-events-none opacity-50' : ''} + /> +
+ Page + + {pageIndex + 1} + +
+ { + e.preventDefault(); + handleNext(); + }} + aria-disabled={!canNext} + className={!canNext ? 'pointer-events-none opacity-50' : ''} + /> +
+
+ + + + +
+ ); }; diff --git a/src/components/layout/status/list/index.tsx b/src/components/layout/status/list/index.tsx index b0aca43..04a2add 100644 --- a/src/components/layout/status/list/index.tsx +++ b/src/components/layout/status/list/index.tsx @@ -37,11 +37,10 @@ export const StatusList = ({ const [selectAll, setSelectAll] = useState(false); const [statusInput, setStatusInput] = useState(''); const [updatingStatus, setUpdatingStatus] = useState(false); - const [selectedHistoryUserId, setSelectedHistoryUserId] = useState>(); const bulkCreate = useMutation(api.statuses.bulkCreate); - const toggleUser = (id: Id<'users'>) => { + const handleSelectUser = (id: Id<'users'>, e: React.MouseEvent) => { setSelectedUserIds((prev) => prev.some((i) => i === id) ? prev.filter((prevId) => prevId !== id) @@ -49,7 +48,7 @@ export const StatusList = ({ ); }; - const handleSelectAllClick = () => { + const handleSelectAll = () => { if (selectAll) setSelectedUserIds([]); else setSelectedUserIds(statuses.map((s) => s.user.id)); setSelectAll(!selectAll); @@ -110,7 +109,7 @@ export const StatusList = ({
+
diff --git a/src/components/layout/status/table/index.tsx b/src/components/layout/status/table/index.tsx index 91b9b33..614d0da 100644 --- a/src/components/layout/status/table/index.tsx +++ b/src/components/layout/status/table/index.tsx @@ -18,6 +18,7 @@ import { import { toast } from 'sonner'; import { ccn, formatTime, formatDate } from '@/lib/utils'; import { Clock, Calendar, CheckCircle2 } from 'lucide-react'; +import { StatusHistory } from '@/components/layout/status'; type StatusTableProps = { preloadedUser: Preloaded; @@ -39,7 +40,7 @@ export const StatusTable = ({ const bulkCreate = useMutation(api.statuses.bulkCreate); - const toggleUser = (id: Id<'users'>) => { + const handleSelectUser = (id: Id<'users'>, e: React.MouseEvent) => { setSelectedUserIds((prev) => prev.some((i) => i === id) ? prev.filter((prevId) => prevId !== id) @@ -47,7 +48,7 @@ export const StatusTable = ({ ); }; - const handleSelectAllClick = () => { + const handleSelectAll = () => { if (selectAll) setSelectedUserIds([]); else setSelectedUserIds(statuses.map((s) => s.user.id)); setSelectAll(!selectAll); @@ -61,7 +62,6 @@ export const StatusTable = ({ throw new Error('Status must be between 3 & 80 characters'); if (selectedUserIds.length === 0 && user?.id) await bulkCreate({ message, userIds: [user.id] }); - else throw new Error("Hmm.. this shouldn't happen"); await bulkCreate({ message, userIds: selectedUserIds }); toast.success('Status updated.'); setSelectedUserIds([]); @@ -76,224 +76,77 @@ export const StatusTable = ({ const containerCn = ccn({ context: tvMode, - className: - 'flex flex-col mx-auto items-center\ - sm:w-5/6 md:w-3/4 lg:w-2/3 xl:w-1/2 min-w-[450px]', - on: 'mt-8', - off: 'px-10', + className: 'mx-auto', + on: 'lg:w-11/12 w-full', + off: 'w-5/6', }); - const headerCn = ccn({ context: tvMode, - className: 'w-full', - on: 'hidden', - off: 'flex mb-3 justify-between items-center', + className: 'w-full mb-2 flex justify-between', + on: 'mt-25', + off: 'mb-2', }); - - const selectAllIconCn = ccn({ - context: selectAll, - className: 'w-4 h-4', - on: 'text-green-500', - off: '', - }); - - const cardContainerCn = ccn({ + const thCn = ccn({ context: tvMode, - className: 'w-full space-y-2', - on: 'text-primary', - off: '', + className: 'py-4 px-4 border font-semibold ', + on: 'lg:text-5xl xl:min-w-[420px]', + off: 'lg:text-4xl xl:min-w-[320px]', }); + const tdCn = ccn({ + context: tvMode, + className: 'py-2 px-2 border', + on: 'lg:text-4xl', + off: 'lg:text-3xl', + }); + const tCheckboxCn = `py-3 px-4 border`; + const checkBoxCn = `lg:scale-200 cursor-pointer`; return (
-
- +
{!tvMode && ( -
- Miss the old table? - - Find it here! +
+

Tired of the old table?

+ + Try the new status list!
)}
- -
- {statuses.map((status) => { - const { user: u, status: s } = status; - const isSelected = selectedUserIds.includes(u.id); - const isUpdatedByOther = !!s?.updatedBy; - return ( - toggleUser(u.id)} - > - {isSelected && ( -
- -
- )} - -
-
- -
- -
-
-
-

- {u.name ?? 'Technician'} -

- -
-

{s?.message ?? 'No status yet.'}

-
-
- -
-
- - - {s ? formatTime(s.updatedAt) : '--:--'} - -
-
- - - {s ? formatDate(s.updatedAt) : '--/--'} - -
- - {isUpdatedByOther && s.updatedBy && ( -
- - -
-

Updated by

- {s.updatedBy.name ?? 'User'} -
-
-
- )} -
-
-
-
-
-
- ); - })} -
- - {statuses.length === 0 && ( - -

- No status updates have been made in the past day. -

-
- )} - - {!tvMode && ( - -
-

Update Status

-
-
- setStatusInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey && !updatingStatus) { - e.preventDefault(); - void handleUpdateStatus(); - } - }} + + + + {!tvMode && ( + + )} + + + + + + + + +
+ handleSelectAll()} /> - - {selectedUserIds.length > 0 - ? `Update ${selectedUserIds.length} ${selectedUserIds.length > 1 ? 'users' : 'user'}` - : 'Update Status'} - - - -
+
Technician - - + + Status + - - - - )} + Updated At
); }; diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..df87296 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,5 @@ +export const PASSWORD_MIN = 8; +export const PASSWORD_MAX = 100; +export const PASSWORD_REGEX = /^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u; + +export type Timestamp = number | string | Date; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c35ee5e..dc5994f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,6 @@ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import { type Timestamp } from '@/lib/types'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -19,11 +20,6 @@ export const ccn = ({ return twMerge(className, context ? on : off); }; -export const PASSWORD_MIN = 8; -export const PASSWORD_MAX = 100; -export const PASSWORD_REGEX = /^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u; - -type Timestamp = number | string | Date; const toDate = (ts: Timestamp): Date | null => { if (ts instanceof Date) return isNaN(ts.getTime()) ? null : ts;