diff --git a/src/components/groups/group-member-list.tsx b/src/components/groups/group-member-list.tsx index f89ac4a6f..0605a9560 100644 --- a/src/components/groups/group-member-list.tsx +++ b/src/components/groups/group-member-list.tsx @@ -1,13 +1,16 @@ "use client"; +import { nudgePendingMembers } from "@actions/meeting/nudge/action"; import { Add, ArrowBack, People, Settings, Share } from "@mui/icons-material"; import { Avatar, + Box, Button, Dialog, DialogContent, DialogTitle, IconButton, + MenuItem, Tab, Tabs, Typography, @@ -15,15 +18,97 @@ import { import Link from "next/link"; import { useMemo, useState } from "react"; import { FilterChip } from "@/components/ui/filter-chip"; +import MeetingCard from "@/components/ui/meeting-card"; import type { SelectGroup } from "@/db/schema"; import { useIsMobile } from "@/hooks/use-mobile"; +import { toMeetingCardProps } from "@/lib/meeting-card/mapper"; +import { buildScheduledLabel } from "@/lib/meetings/utils"; import type { MeetingWithStats } from "@/server/data/groups/queries"; +import { useSnackbar } from "../ui/snackbar-provider"; import { AddMemberDialog } from "./add-member-dialog"; import { GroupSettingsForm } from "./group-settings-form"; -import { MeetingRow } from "./meeting-row"; import { MembersList } from "./members-list"; import type { GroupMember } from "./types"; +const cardGridSx = { + display: { xs: "flex", sm: "grid" } as const, + flexDirection: "column" as const, + gridTemplateColumns: { sm: "repeat(2, 1fr)", lg: "repeat(3, 1fr)" }, + gap: { xs: 1.5, sm: 2 }, +}; + +function GroupMeetingCard({ + meeting, + currentMemberId, + status, +}: { + meeting: MeetingWithStats; + currentMemberId: string; + status: "actionRequired" | "upcoming" | null; +}) { + const [isNudging, setIsNudging] = useState(false); + const { showSuccess, showError } = useSnackbar(); + const isOwner = meeting.hostId === currentMemberId; + + const scheduledLabel = + meeting.scheduledDate && + meeting.scheduledFromTime && + meeting.scheduledToTime + ? buildScheduledLabel( + meeting.scheduledDate, + meeting.scheduledFromTime, + meeting.scheduledToTime, + ) + : undefined; + + const cardProps = toMeetingCardProps( + { ...meeting, hostDisplayName: meeting.hostName }, + { responderCount: meeting.respondedCount, scheduledLabel }, + ); + + const nudgeItem = isOwner + ? (close: () => void) => ( + { + e.stopPropagation(); + close(); + setIsNudging(true); + try { + const result = await nudgePendingMembers(meeting.id); + if (result.success) showSuccess(result.message); + else showError(result.message); + } catch { + showError("Failed to send nudge. Please try again."); + } finally { + setIsNudging(false); + } + }} + > + + Nudge Members + + ) + : undefined; + + return ( + = meeting.totalMembers} + isUpcoming={status === "upcoming"} + isPast={Boolean( + meeting.scheduledDate && + meeting.scheduledDate < new Date(new Date().setHours(0, 0, 0, 0)), + )} + extraMenuItems={nudgeItem} + /> + ); +} + interface GroupMemberListProps { group: SelectGroup; members: GroupMember[]; @@ -49,34 +134,47 @@ export function GroupMemberList({ "all" | "created" | "upcoming" >("all"); - const { - meetingsPendingAvailability, - meetingsPendingAvailabilityMap, - allMeetings, - } = useMemo(() => { - const getSortDate = (meeting: MeetingWithStats) => - meeting.scheduledDate - ? new Date(meeting.scheduledDate) - : new Date(meeting.dates[0]); - - const meetingsPendingAvailability = meetings - .filter((meeting) => !meeting.userHasResponded) - .sort((a, b) => getSortDate(a).getTime() - getSortDate(b).getTime()); - - const meetingsPendingAvailabilityMap = new Set( - meetingsPendingAvailability.map((m) => m.id), - ); + const { allMeetingsSorted, upcomingSet } = useMemo(() => { + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const windowEnd = startOfToday.getTime() + 3 * 24 * 60 * 60 * 1000; - const allMeetings = [...meetings].sort( - (a, b) => getSortDate(a).getTime() - getSortDate(b).getTime(), + const upcomingSet = new Set( + meetings + .filter((m) => { + if (!m.scheduledDate) return false; + const t = m.scheduledDate.getTime(); + return t >= startOfToday.getTime() && t <= windowEnd; + }) + .map((m) => m.id), ); - return { - meetingsPendingAvailability, - meetingsPendingAvailabilityMap, - allMeetings, + const getPriority = (m: MeetingWithStats) => { + if (!m.userHasResponded && !m.scheduledDate) return 0; + if ( + m.respondedCount >= m.totalMembers && + m.hostId === currentMemberId && + !m.scheduledDate + ) + return 1; + if (upcomingSet.has(m.id)) return 2; + if (m.scheduledDate) return 3; + return 4; }; - }, [meetings]); + + const getSortDate = (m: MeetingWithStats) => + m.scheduledDate + ? m.scheduledDate.getTime() + : new Date(m.dates[0]).getTime(); + + const allMeetingsSorted = [...meetings].sort((a, b) => { + const pDiff = getPriority(a) - getPriority(b); + if (pDiff !== 0) return pDiff; + return getSortDate(a) - getSortDate(b); + }); + + return { allMeetingsSorted, upcomingSet }; + }, [meetings, currentMemberId]); const counts = { all: meetings.length, @@ -86,7 +184,7 @@ export function GroupMemberList({ ).length, }; - const displayedMeetings = allMeetings.filter((meeting) => { + const displayedMeetings = allMeetingsSorted.filter((meeting) => { if (activeFilter === "created") return meeting.hostId === currentMemberId; if (activeFilter === "upcoming") { return ( @@ -261,49 +359,24 @@ export function GroupMemberList({ {tab === 0 && (
{meetings.length > 0 ? ( -
-
-

- Action Required ({meetingsPendingAvailability.length}) -

- {meetingsPendingAvailability.map((meeting) => ( -
- -
- ))} -
-
-

- All ({displayedMeetings.length}) -

- {displayedMeetings.map((meeting) => ( -
- new Date() - ? "upcoming" - : null - } - /> -
- ))} -
-
+ + {displayedMeetings.map((meeting) => ( + + ))} + ) : (

No meetings yet!

diff --git a/src/components/groups/meeting-row.tsx b/src/components/groups/meeting-row.tsx deleted file mode 100644 index 96cd43930..000000000 --- a/src/components/groups/meeting-row.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { nudgePendingMembers } from "@actions/meeting/nudge/action"; -import { People } from "@mui/icons-material"; -import { IconButton, Menu, MenuItem } from "@mui/material"; -import { Calendar, Clock, MoreVertical, Users } from "lucide-react"; -import Link from "next/link"; -import { useState } from "react"; -import { useSnackbar } from "@/components/ui/snackbar-provider"; -import type { MeetingWithStats } from "@/server/data/groups/queries"; -import { DateRange } from "./date-range"; -import { StatusBadge } from "./status-badge"; - -export function MeetingRow({ - meeting, - currentMemberId, - status, -}: { - meeting: MeetingWithStats; - currentMemberId: string; - status: "actionRequired" | "upcoming" | null; -}) { - const [anchorEl, setAnchorEl] = useState(null); - const [isNudging, setIsNudging] = useState(false); - const { showSuccess, showError } = useSnackbar(); - const open = Boolean(anchorEl); - const isOwner = meeting.hostId === currentMemberId; - const createdByLabel = isOwner - ? "Created by You" - : `Created by ${meeting.hostName}`; - - return ( -
- -
-
-

{meeting.title}

- {status && } -
- -
-
- - - - -
- -
- - {meeting.totalMembers} members -
- -
- - - {meeting.respondedCount}/{meeting.totalMembers} responded - -
-
-
- -
-

- {createdByLabel} -

- {isOwner && ( - <> - { - e.stopPropagation(); - setAnchorEl(open ? null : e.currentTarget); - }} - > - - - - setAnchorEl(null)} - transformOrigin={{ horizontal: "right", vertical: "top" }} - anchorOrigin={{ horizontal: "right", vertical: "bottom" }} - > - { - e.stopPropagation(); - setAnchorEl(null); - setIsNudging(true); - try { - const result = await nudgePendingMembers(meeting.id); - if (result.success) { - showSuccess(result.message); - } else { - showError(result.message); - } - } catch { - showError("Failed to send nudge. Please try again."); - } finally { - setIsNudging(false); - } - }} - > - - Nudge Members - - - - )} -
-
- ); -} diff --git a/src/components/ui/meeting-card.tsx b/src/components/ui/meeting-card.tsx index 656e603be..b98ab02cc 100644 --- a/src/components/ui/meeting-card.tsx +++ b/src/components/ui/meeting-card.tsx @@ -16,13 +16,15 @@ import { Typography, } from "@mui/material"; import Link from "next/link"; -import { type ElementType, useId, useState } from "react"; +import { type ElementType, type ReactNode, useId, useState } from "react"; import type { MeetingCardViewModel } from "@/lib/meeting-card/mapper"; import { getDeleteLeaveAction } from "@/lib/meetings/delete-leave-action"; interface MeetingCardProps extends MeetingCardViewModel { isOwner: boolean; onDeleteLeave?: () => void; + extraMenuItems?: (close: () => void) => ReactNode; + totalMembers?: number; needsAvailability?: boolean; allAvailabilityFilled?: boolean; isUpcoming?: boolean; @@ -76,6 +78,8 @@ const MeetingCard = ({ meetingLink, isOwner, onDeleteLeave, + extraMenuItems, + totalMembers, needsAvailability = false, allAvailabilityFilled = false, isUpcoming = false, @@ -140,7 +144,7 @@ const MeetingCard = ({ {meetingOrganizer} - {onDeleteLeave && ( + {(onDeleteLeave || extraMenuItems) && ( <> - - - - - {actionLabel} - + {onDeleteLeave && ( + + + + + {actionLabel} + + )} + {extraMenuItems?.(() => setAnchorEl(null))} )} @@ -186,7 +193,14 @@ const MeetingCard = ({ - + {location && } diff --git a/src/server/data/groups/queries.ts b/src/server/data/groups/queries.ts index c4c768331..6d1870cc2 100644 --- a/src/server/data/groups/queries.ts +++ b/src/server/data/groups/queries.ts @@ -268,6 +268,8 @@ export async function getGroupsWithDetails( export type MeetingWithStats = SelectMeeting & { hostName: string; scheduledDate: Date | null; + scheduledFromTime: string | null; + scheduledToTime: string | null; totalMembers: number; respondedCount: number; userHasResponded: boolean; @@ -327,14 +329,23 @@ export async function getGroupMeetingsWithStats( ); const userRespondedSet = new Set(userResponses.map((r) => r.meetingId)); const scheduledMap = new Map( - scheduledBlocks.map((s) => [s.meetingId, s.scheduledDate]), + scheduledBlocks.map((s) => [ + s.meetingId, + { + scheduledDate: s.scheduledDate, + scheduledFromTime: s.scheduledFromTime, + scheduledToTime: s.scheduledToTime, + }, + ]), ); const hostMap = new Map(hosts.map((h) => [h.id, h.displayName])); return groupMeetings.map((meeting) => ({ ...meeting, hostName: hostMap.get(meeting.hostId) ?? "Unknown", - scheduledDate: scheduledMap.get(meeting.id) ?? null, + scheduledDate: scheduledMap.get(meeting.id)?.scheduledDate ?? null, + scheduledFromTime: scheduledMap.get(meeting.id)?.scheduledFromTime ?? null, + scheduledToTime: scheduledMap.get(meeting.id)?.scheduledToTime ?? null, totalMembers, respondedCount: responseMap.get(meeting.id) ?? 0, userHasResponded: userRespondedSet.has(meeting.id),