// modules/admin/pages/user_groups/ViewGroup.jsx import { useRef, useMemo, useState, useEffect, useCallback } from "react"; import { useParams } from "react-router-dom"; import { House, Users, QrCode, Download, Copy, Check, Link, UserCheck, Search, X } from "lucide-react"; import { QRCodeCanvas } from "qrcode.react"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import DataTable from "@/components/generic/Table/DataTable"; import { FilterSheet } from "@/components/generic/Sheet/FilterSheet"; import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog"; import { AddSheet } from "@/components/generic/Sheet/AddSheet"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList, } from "@/components/ui/command"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; import { useUserGroups } from "@/contexts/AdminUserGroupContext"; import { useUsers } from "@/contexts/AdminUserContext"; import { useDateFormat } from "@/hooks/useDateFormat"; import { buildDataColumns, columnPinning } from "../../config/user_groups/view/columns.config"; import { buildToolbarActions } from "../../config/user_groups/view/toolbar.config"; import { buildSelectionActions } from "../../config/user_groups/view/selection.config"; import { buildRowActions } from "../../config/user_groups/view/rowActions.config"; import { getTimestamp } from "@/utils/timestamp.util"; // ─── Invite Link Dialog ─────────────────────────────────────────────────────── function InviteLinkDialog({ open, onOpenChange, group }) { const [copiedLink, setCopiedLink] = useState(false); const [copiedCode, setCopiedCode] = useState(false); const inviteUrl = group?.group_code ? `${window.location.origin}/signup?group_code=${group.group_code}` : null; const handleCopyLink = async () => { if (!inviteUrl) return; await navigator.clipboard.writeText(inviteUrl); setCopiedLink(true); setTimeout(() => setCopiedLink(false), 2000); }; const handleCopyCode = async () => { if (!group?.group_code) return; await navigator.clipboard.writeText(group.group_code); setCopiedCode(true); setTimeout(() => setCopiedCode(false), 2000); }; const handleDownload = () => { const canvas = document.querySelector("[data-qr='group-invite']"); if (!canvas) return; const url = canvas.toDataURL("image/png"); const link = document.createElement("a"); link.href = url; link.download = `QR_${group.group_code}.png`; link.click(); }; return ( Invite link
{/* QR code */}
{inviteUrl && ( )}
{/* Download QR */}
or share the link
{/* Invite link */}

{inviteUrl}

); } // ─── Assign Group Dialog ────────────────────────────────────────────────────── function AssignGroupDialog({ open, onOpenChange, userCount, groups, loading, onAssign }) { const [selectedGroupId, setSelectedGroupId] = useState(null); const [search, setSearch] = useState(''); useEffect(() => { if (open) { setSelectedGroupId(null); setSearch(''); } }, [open]); const realGroups = groups.filter((g) => (g.group_code ?? '') !== 'NOGRP' && g.is_active); const filtered = realGroups.filter((g) => { if (!search.trim()) return true; const q = search.toLowerCase(); return g.name.toLowerCase().includes(q) || (g.group_code ?? '').toLowerCase().includes(q); }); return ( Assign to Group Select a group to assign{" "} {userCount === 1 ? "this user" : `${userCount} users`} to. They will be added to the selected group. {/* ── Search input ── */}
setSearch(e.target.value)} placeholder="Search groups..." className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground py-0.5" /> {search && ( )}
{/* ── Group list ── */} {filtered.length === 0 ? ( {search ? `No results for "${search}".` : 'No groups available.'} ) : ( {filtered.map((g) => { const isSelected = selectedGroupId === g.group_id; return ( setSelectedGroupId(isSelected ? null : g.group_id)} className={cn( "flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors", "aria-selected:bg-transparent data-selected:bg-transparent", isSelected ? "bg-primary/[0.06] hover:bg-primary/[0.10]" : "hover:bg-muted/50" )} > {/* Embossed checkbox */} {isSelected && ( )} {/* Name */} {g.name} {/* Group code pill */} {g.group_code} ); })} )}
); } // ─── Page ───────────────────────────────────────────────────────────────────── export default function ViewGroup() { const { groupId } = useParams(); const tableRefsRef = useRef({ getFilters: () => [], getSort: () => [], resetSelection: () => {}, setFilters: () => {}, }); const [addMemberOpen, setAddMemberOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false); const [archiveTarget, setArchiveTarget] = useState(null); const [archiveIds, setArchiveIds] = useState(null); const [memberAttrs, setMemberAttrs] = useState([]); const [assignOpen, setAssignOpen] = useState(false); const [assignTarget, setAssignTarget] = useState(null); // single row const [assignIds, setAssignIds] = useState(null); // bulk ids[] const { group, groups, members, usersNotIn, pagination, setPagination, loading, fetchGroups, fetchGroup, fetchUsersNotInGroup, addUsersToGroup, removeUsersFromGroup, } = useUserGroups(); // Members belong to the Users model (full_name, DOB, occupation, createdBy…), // not UserGroups — the group-level field-values endpoint doesn't know these // fields at all, so filter picklists must come from the Users endpoint. const { fetchUserFieldValues } = useUsers(); const isNoGroup = group?.group_code === 'NOGRP'; useEffect(() => { if (!groupId) return; fetchGroup(groupId).then((res) => { const attrs = res?.data?.members?.attributes ?? []; setMemberAttrs(attrs); }); }, []); const handleRefsReady = (refs) => { tableRefsRef.current = refs; }; const exportConfig = { allData: members, attributes: memberAttrs, filename: `${getTimestamp()}_Group_${groupId}_Members`, sheetName: "Members", }; const rowActions = buildRowActions({ onRemove: (row) => setArchiveTarget(row), onAssign: (row) => openAssignDialog(row), isNoGroup, }); const toolbarActions = buildToolbarActions({ fetchGroup, gid: groupId, pagination, exportConfig, onAddMember: () => setAddMemberOpen(true), getFilters: () => tableRefsRef.current.getFilters(), getSort: () => tableRefsRef.current.getSort(), }); const selectionActions = buildSelectionActions({ exportConfig, onRemoveMember: (row) => setArchiveTarget(row), onRemoveMembers: (ids) => setArchiveIds(ids), onAssignMembers: (ids) => openAssignDialog(ids), isNoGroup, }); const columns = useMemo( () => buildDataColumns(memberAttrs, rowActions), [memberAttrs, isNoGroup], ); const handleRemoveSuccess = () => { setArchiveTarget(null); setArchiveIds(null); tableRefsRef.current.resetSelection?.(); fetchGroup(groupId, { page: 1, limit: pagination.limit }); }; const openAssignDialog = (rowOrIds) => { fetchGroups({ limit: 100 }); if (Array.isArray(rowOrIds)) { setAssignIds(rowOrIds); setAssignTarget(null); } else { setAssignTarget(rowOrIds); setAssignIds(null); } setAssignOpen(true); }; const handleAssign = async (targetGroupId) => { const ids = assignIds ?? [assignTarget?.user_id]; await addUsersToGroup(targetGroupId, ids); setAssignOpen(false); setAssignTarget(null); setAssignIds(null); tableRefsRef.current.resetSelection?.(); fetchGroup(groupId, { page: 1, limit: pagination.limit }); }; const breadcrumbItems = [ { label: "Home", icon: , to: "/admin" }, { label: "User Groups", to: "/admin/groups" }, { label: group?.name ?? "View Group" }, ]; const { fmtDate } = useDateFormat(); const formattedCreated = group?.createdAt ? fmtDate(group.createdAt) : "—"; const formattedUpdated = group?.updatedAt ? fmtDate(group.updatedAt) : "—"; const handleFetch = useCallback( (params) => fetchGroup(groupId, params), [groupId] ); return (
{/* ── Group detail card ──────────────────────────────────────────── */}

{group?.name ?? "—"}

{group?.is_active ? "Active" : "Inactive"}

{group?.description ?? "—"}

{/* ── Generate invite link button — hidden for NOGRP (system default group) ── */} {group?.group_code && group.group_code !== 'NOGRP' && ( )}
{[ { label: "Group Code", value: group?.group_code ?? "—" }, { label: "Members", value: pagination?.totalRecords ?? 0, icon: }, { label: "Created", value: formattedCreated }, { label: "Last Updated", value: formattedUpdated }, ].map(({ label, value, icon }) => (
{label} {icon}{value}
))}
{/* ── Members table ──────────────────────────────────────────────── */}
( )} columnPinning={columnPinning} toolbarActions={toolbarActions} selectionActions={selectionActions} recordLabel="member" emptyMessage="No members in this group yet." />
{/* ── Invite link dialog ────────────────────────────────────────────── */} {/* ── Assign to group dialog (NOGRP members only) ───────────────────── */} {/* ── Add member ───────────────────────────────────────────────────── */} fetchUsersNotInGroup(groupId)} idKey="user_id" labelKey="full_name" warningKey="current_group" onSubmit={async (user_ids) => { await addUsersToGroup(groupId, user_ids); fetchGroup(groupId, { page: 1, limit: pagination.limit }); }} /> {/* ── Single remove ─────────────────────────────────────────────────── */} !v && setArchiveTarget(null)} entity={archiveTarget} entityLabel="Member" getName={(m) => m?.personal_info?.name?.full_name ?? m?.email} onArchive={(m) => removeUsersFromGroup(groupId, [m?.user_id])} loading={loading} onSuccess={handleRemoveSuccess} /> {/* ── Bulk remove ───────────────────────────────────────────────────── */} !v && setArchiveIds(null)} ids={archiveIds ?? []} entityLabel="Member" onArchive={({ ids }) => removeUsersFromGroup(groupId, ids)} loading={loading} onSuccess={handleRemoveSuccess} />
); }