diff --git a/bun.lock b/bun.lock index c1c1bfe..21dbdeb 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", @@ -544,6 +545,8 @@ "@prisma/instrumentation": ["@prisma/instrumentation@6.14.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-Po/Hry5bAeunRDq0yAQueKookW3glpP+qjjvvyOfm6dI2KG5/Y6Bgg3ahyWd7B0u2E+Wf9xRk2rtdda7ySgK1A=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], @@ -586,6 +589,8 @@ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/convex/statuses.ts b/convex/statuses.ts index 0183bb4..fb555f0 100644 --- a/convex/statuses.ts +++ b/convex/statuses.ts @@ -16,21 +16,14 @@ type StatusRow = { user: { id: Id<'users'>; name: string | null; - imageId: Id<'_storage'> | null; imageUrl: string | null; - }; - latest: { + }, + status: { id: Id<'statuses'>; message: string; updatedAt: number; - updatedBy: Id<'users'>; - } | null; - updatedByUser: { - id: Id<'users'>; - name: string | null; - imageId: Id<'_storage'> | null; - imageUrl: string | null; - } | null; + updatedBy: StatusRow['user'] | null; + } | null, }; // CHANGED: typed helpers @@ -154,6 +147,7 @@ export const getCurrentForUser = query({ return await latestStatusForOwner(ctx, userId); }, }); + const getName = (u: Doc<'users'>): string | null => 'name' in u && typeof u.name === 'string' ? u.name : null; @@ -176,17 +170,17 @@ export const getCurrentForAll = query({ return await Promise.all( users.map(async (u) => { // Resolve user's current or latest status - let status: Doc<'statuses'> | null = null; + let curStatus: Doc<'statuses'> | null = null; if ('currentStatusId' in u && u.currentStatusId) { - status = await ctx.db.get(u.currentStatusId); + curStatus = await ctx.db.get(u.currentStatusId); } - if (!status) { + if (!curStatus) { const [latest] = await ctx.db .query('statuses') .withIndex('by_user_updatedAt', (q) => q.eq('userId', u._id)) .order('desc') .take(1); - status = latest ?? null; + curStatus = latest ?? null; } // User display + URL @@ -196,29 +190,27 @@ export const getCurrentForAll = query({ : null; // Updated by (if different) + URL - let updatedByUser: StatusRow['updatedByUser'] | null = null; - if (status && status.updatedBy !== u._id) { - const updater = await ctx.db.get(status.updatedBy); + let updatedByUser: StatusRow['user'] | null = null; + if (curStatus && curStatus.updatedBy !== u._id) { + const updater = await ctx.db.get(curStatus.updatedBy); if (!updater) throw new ConvexError('Updater not found.'); const updaterImageId = getImageId(updater); const updaterImageUrl = updaterImageId ? await ctx.storage.getUrl(updaterImageId) : null; - updatedByUser = { id: updater._id, name: getName(updater), - imageId: updaterImageId, imageUrl: updaterImageUrl, }; } - const latest: StatusRow['latest'] = status + const status: StatusRow['status'] = curStatus ? { - id: status._id, - message: status.message, - updatedAt: status.updatedAt, - updatedBy: status.updatedBy, + id: curStatus._id, + message: curStatus.message, + updatedAt: curStatus.updatedAt, + updatedBy: updatedByUser, } : null; @@ -226,11 +218,9 @@ export const getCurrentForAll = query({ user: { id: u._id, name: getName(u), - imageId: userImageId, imageUrl: userImageUrl, }, - latest, - updatedByUser, + status, }; }), ); diff --git a/package.json b/package.json index 8163ba3..13eafd4 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", diff --git a/src/components/layout/status/history/index.tsx b/src/components/layout/status/history/index.tsx new file mode 100644 index 0000000..494c083 --- /dev/null +++ b/src/components/layout/status/history/index.tsx @@ -0,0 +1,43 @@ +'use client'; +import Image from 'next/image'; +import Link from 'next/link'; +import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react'; +import { api } from '~/convex/_generated/api'; +import { type Id } from '~/convex/_generated/dataModel'; +import { useTVMode } from '@/components/providers'; +import { + DrawerClose, + DrawerContent, + DrawerFooter, + DrawerHeader, + DrawerTitle, + Pagination, + PaginationContent, + PaginationLink, + PaginationItem, + PaginationNext, + PaginationPrevious, + ScrollArea, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Button, +} from '@/components/ui'; +import { toast } from 'sonner'; + +type StatusHistoryProps = { + preloadedUser: Preloaded; +}; + +export const StatusHistory = ({ + preloadedUser, +}: StatusHistoryProps) => { + const user = usePreloadedQuery(preloadedUser); + + return ( + + ); +}; diff --git a/src/components/layout/status/index.tsx b/src/components/layout/status/index.tsx new file mode 100644 index 0000000..6b46655 --- /dev/null +++ b/src/components/layout/status/index.tsx @@ -0,0 +1,3 @@ +export { StatusHistory } from './history'; +export { StatusList } from './list'; +export { StatusTable } from './table'; diff --git a/src/components/layout/status/list/index.tsx b/src/components/layout/status/list/index.tsx index 0a7de6d..b2c6e4e 100644 --- a/src/components/layout/status/list/index.tsx +++ b/src/components/layout/status/list/index.tsx @@ -132,9 +132,9 @@ export const StatusList = ({
{statuses.map((status) => { - const { user: u, latest, updatedByUser } = status; + const { user: u, status: s } = status; const isSelected = selectedUserIds.includes(u.id); - const isUpdatedByOther = !!updatedByUser; + const isUpdatedByOther = !!s?.updatedBy; return ( -

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

+

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

@@ -200,7 +200,7 @@ export const StatusList = ({
- {latest ? formatTime(latest.updatedAt) : '--:--'} + {s ? formatTime(s.updatedAt) : '--:--'}
@@ -208,21 +208,21 @@ export const StatusList = ({ className={tvMode ? 'w-6 h-6' : 'w-5 h-5'} /> - {latest ? formatDate(latest.updatedAt) : '--/--'} + {s ? formatDate(s.updatedAt) : '--/--'}
- {isUpdatedByOther && updatedByUser && ( + {isUpdatedByOther && s.updatedBy && (

Updated by

- {updatedByUser.name ?? 'User'} + {s.updatedBy.name ?? 'User'}
@@ -264,7 +264,7 @@ export const StatusList = ({ onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !updatingStatus) { e.preventDefault(); - handleUpdateStatus(); + void handleUpdateStatus(); } }} /> diff --git a/src/components/layout/status/table/index.tsx b/src/components/layout/status/table/index.tsx index e69de29..91b9b33 100644 --- a/src/components/layout/status/table/index.tsx +++ b/src/components/layout/status/table/index.tsx @@ -0,0 +1,299 @@ +'use client'; +import Link from 'next/link'; +import { useState } from 'react'; +import { type Preloaded, usePreloadedQuery, useMutation } from 'convex/react'; +import { api } from '~/convex/_generated/api'; +import { type Id } from '~/convex/_generated/dataModel'; +import { useTVMode } from '@/components/providers'; +import { + BasedAvatar, + Button, + Card, + CardContent, + Drawer, + DrawerTrigger, + Input, + SubmitButton, +} from '@/components/ui'; +import { toast } from 'sonner'; +import { ccn, formatTime, formatDate } from '@/lib/utils'; +import { Clock, Calendar, CheckCircle2 } from 'lucide-react'; + +type StatusTableProps = { + preloadedUser: Preloaded; + preloadedStatuses: Preloaded; +}; + +export const StatusTable = ({ + preloadedUser, + preloadedStatuses, +}: StatusTableProps) => { + const user = usePreloadedQuery(preloadedUser); + const statuses = usePreloadedQuery(preloadedStatuses); + + const { tvMode } = useTVMode(); + const [selectedUserIds, setSelectedUserIds] = useState[]>([]); + const [selectAll, setSelectAll] = useState(false); + const [statusInput, setStatusInput] = useState(''); + const [updatingStatus, setUpdatingStatus] = useState(false); + + const bulkCreate = useMutation(api.statuses.bulkCreate); + + const toggleUser = (id: Id<'users'>) => { + setSelectedUserIds((prev) => + prev.some((i) => i === id) + ? prev.filter((prevId) => prevId !== id) + : [...prev, id], + ); + }; + + const handleSelectAllClick = () => { + if (selectAll) setSelectedUserIds([]); + else setSelectedUserIds(statuses.map((s) => s.user.id)); + setSelectAll(!selectAll); + }; + + const handleUpdateStatus = async () => { + const message = statusInput.trim(); + setUpdatingStatus(true); + try { + if (message.length < 3 || message.length > 80) + 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([]); + setSelectAll(false); + setStatusInput(''); + } catch (error) { + toast.error(`Update failed. ${error as Error}`); + } finally { + setUpdatingStatus(false); + } + }; + + 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', + }); + + const headerCn = ccn({ + context: tvMode, + className: 'w-full', + on: 'hidden', + off: 'flex mb-3 justify-between items-center', + }); + + const selectAllIconCn = ccn({ + context: selectAll, + className: 'w-4 h-4', + on: 'text-green-500', + off: '', + }); + + const cardContainerCn = ccn({ + context: tvMode, + className: 'w-full space-y-2', + on: 'text-primary', + off: '', + }); + + return ( +
+
+
+ + {!tvMode && ( +
+ Miss the old table? + + Find it here! + +
+ )} +
+
+ +
+ {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(); + } + }} + /> + + {selectedUserIds.length > 0 + ? `Update ${selectedUserIds.length} ${selectedUserIds.length > 1 ? 'users' : 'user'}` + : 'Update Status'} + +
+
+
+ + + + + +
+
+
+ )} +
+ ); +}; diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx index d7e8d59..a54f406 100644 --- a/src/components/ui/index.tsx +++ b/src/components/ui/index.tsx @@ -43,9 +43,29 @@ export { } from './form'; export { Input } from './input'; export { Label } from './label'; +export { + Pagination, + PaginationContent, + PaginationLink, + PaginationItem, + PaginationPrevious, + PaginationNext, + PaginationEllipsis, +} from './pagination' export { Progress } from './progress'; +export { ScrollArea, ScrollBar } from './scroll-area' export { Separator } from './separator'; export { StatusMessage } from './status-message'; export { SubmitButton } from './submit-button'; +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} from './table'; export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs'; export { Toaster } from './sonner'; diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx new file mode 100644 index 0000000..0d18541 --- /dev/null +++ b/src/components/ui/pagination.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizontalIcon, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Pagination({ className, ...props }: React.ComponentProps<"nav">) { + return ( +