diff --git a/convex/statuses.ts b/convex/statuses.ts index b4bfa98..c313f59 100644 --- a/convex/statuses.ts +++ b/convex/statuses.ts @@ -1,15 +1,38 @@ import { ConvexError, v } from 'convex/values'; import { getAuthUserId } from '@convex-dev/auth/server'; -import { mutation, query } from './_generated/server'; +import { + type MutationCtx, + type QueryCtx, + mutation, + query, +} from './_generated/server'; import type { Doc, Id } from './_generated/dataModel'; import { paginationOptsValidator } from 'convex/server'; -// NEW: import ctx and data model types -import type { MutationCtx, QueryCtx } from './_generated/server'; - // NEW: shared ctx type for helpers type RWCtx = MutationCtx | QueryCtx; +type StatusRow = { + user: { + id: Id<'users'>, + name: string | null, + imageId: Id<'_storage'> | null, + imageUrl: string | null, + } + latest: { + 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, +}; + // CHANGED: typed helpers const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => { const user = await ctx.db.get(userId); @@ -74,14 +97,14 @@ export const create = mutation({ export const bulkCreate = mutation({ args: { message: v.string(), - ownerIds: v.array(v.id('users')), + userIds: v.array(v.id('users')), updatedBy: v.optional(v.id('users')), }, handler: async (ctx, args) => { const authUserId = await getAuthUserId(ctx); if (!authUserId) throw new ConvexError('Not authenticated.'); - if (args.ownerIds.length === 0) return { statusIds: [] }; + if (args.userIds.length === 0) return { statusIds: [] }; const updatedBy = args.updatedBy ?? authUserId; await ensureUser(ctx, updatedBy); @@ -96,7 +119,7 @@ export const bulkCreate = mutation({ // Sequential to keep load predictable; switch to Promise.all // if your ownerIds lists are small and bounded. - for (const userId of args.ownerIds) { + for (const userId of args.userIds) { await ensureUser(ctx, userId); const statusId = await ctx.db.insert('statuses', { @@ -109,7 +132,6 @@ export const bulkCreate = mutation({ await ctx.db.patch(userId, { currentStatusId: statusId }); statusIds.push(statusId); } - return { statusIds }; }, }); @@ -132,6 +154,15 @@ 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; + +const getImageId = (u: Doc<'users'>): Id<'_storage'> | null => { + if (!('image' in u)) return null; + const img = + (u as { image?: unknown }).image as string | undefined; + return img && img.length > 0 ? (img as Id<'_storage'>) : null; +}; /** * Current statuses for all users. @@ -140,24 +171,70 @@ export const getCurrentForUser = query({ */ export const getCurrentForAll = query({ args: {}, - handler: async (ctx) => { + handler: async (ctx): Promise => { const users = await ctx.db.query('users').collect(); - const results = await Promise.all( + return await Promise.all( users.map(async (u) => { - let status = null; - if (u.currentStatusId) { + // Resolve user's current or latest status + let status: Doc<'statuses'> | null = null; + if ('currentStatusId' in u && u.currentStatusId) { status = await ctx.db.get(u.currentStatusId); } - status ??= await latestStatusForOwner(ctx, u._id); + if (!status) { + const [latest] = await ctx.db + .query('statuses') + .withIndex('by_user_updatedAt', (q) => q.eq('userId', u._id)) + .order('desc') + .take(1); + status = latest ?? null; + } + + // User display + URL + const userImageId = getImageId(u); + const userImageUrl = userImageId + ? await ctx.storage.getUrl(userImageId) + : 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); + 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 + ? { + id: status._id, + message: status.message, + updatedAt: status.updatedAt, + updatedBy: status.updatedBy, + } + : null; + return { - userId: u._id as Id<'users'>, - status, + user: { + id: u._id, + name: getName(u), + imageId: userImageId, + imageUrl: userImageUrl, + }, + latest, + updatedByUser, }; }), ); - - return results; }, }); diff --git a/src/components/layout/status/list/index.tsx b/src/components/layout/status/list/index.tsx index 58e933d..65dd72c 100644 --- a/src/components/layout/status/list/index.tsx +++ b/src/components/layout/status/list/index.tsx @@ -1,8 +1,14 @@ 'use client'; import Link from 'next/link'; import { useState } from 'react'; -import { type Preloaded, usePreloadedQuery } from 'convex/react'; +import { + type Preloaded, + usePreloadedQuery, + useMutation, + useQuery, +} from 'convex/react'; import { api } from '~/convex/_generated/api'; +import { type Id } from '~/convex/_generated/dataModel'; import { useTVMode } from '@/components/providers'; import { BasedAvatar, @@ -29,17 +35,47 @@ export const StatusList = ({ }: StatusListProps) => { const user = usePreloadedQuery(preloadedUser); const statuses = usePreloadedQuery(preloadedStatuses); + const { tvMode } = useTVMode(); - const [selectedUsers, setSelectedUsers] = useState([]); + 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.includes(id) ? prev.filter((i) => i !== id) : [...prev, id] + ); + } const handleSelectAllClick = () => { - if (selectAll) setSelectedUsers([]); - else setSelectedUsers([]); + if (selectAll) setSelectedUserIds([]); + else setSelectedUserIds([]); 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) + throw new Error('You must select at least one user.') + 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: @@ -59,7 +95,7 @@ export const StatusList = ({ const selectAllIconCn = ccn({ context: selectAll, className: 'w-4 h-4', - on: '', + on: 'text-green-500', off: '', }); @@ -81,7 +117,9 @@ export const StatusList = ({ className='flex items-center gap2' > - {selectAll ? 'Unselect All' : 'Select All'} +

+ {selectAll ? 'Unselect All' : 'Select All'} +

{!tvMode && (
@@ -94,7 +132,174 @@ export const StatusList = ({
-
+
+ {statuses.map((status) => { + const { user: u, latest, updatedByUser } = status; + const isSelected = selectedUserIds.includes(u.id); + const isUpdatedByOther = !!updatedByUser; + return ( + toggleUser(u.id)} + > + {isSelected && ( +
+ +
+ )} + +
+
+ +
+ +
+
+
+

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

+ +
+

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

+
+
+ +
+
+ + + {latest ? formatTime(latest.updatedAt.toString()) : '--:--'} + +
+
+ + + {latest ? formatDate(latest.updatedAt.toString()) : '--/--'} + +
+ + {isUpdatedByOther && updatedByUser && ( +
+ + +
+

Updated by

+ {updatedByUser.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(); + handleUpdateStatus(); + } + }} + /> + + {selectedUserIds.length > 0 + ? `Update ${selectedUserIds.length} ${selectedUserIds.length > 1 ? 'users' : 'user'}` + : 'Update Status'} + +
+
+
+ + + + + +
+
+
+ )} + ); }; diff --git a/src/components/layout/status/table/index.tsx b/src/components/layout/status/table/index.tsx new file mode 100644 index 0000000..e69de29