Files
starr-philproperties/src/modules/admin/pages/user_groups/ViewGroup.jsx
T
2026-07-20 08:17:38 +08:00

565 lines
21 KiB
React

// 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>Invite link</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-5 py-2">
{/* QR code */}
<div className="rounded-xl border bg-white p-4 shadow-sm">
{inviteUrl && (
<QRCodeCanvas
data-qr="group-invite"
value={inviteUrl}
size={180}
includeMargin={false}
/>
)}
</div>
{/* Download QR */}
<Button
variant="outline"
className="w-full gap-2"
onClick={handleDownload}
>
<Download className="size-4" />
Download QR code
</Button>
<div className="w-full flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex-1 h-px bg-border" />
or share the link
<div className="flex-1 h-px bg-border" />
</div>
{/* Invite link */}
<div className="w-full flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-lg border bg-muted px-3 py-2 min-w-0">
<p className="font-mono text-xs text-muted-foreground truncate w-64">
{inviteUrl}
</p>
</div>
<Button className="w-full gap-2" onClick={handleCopyLink}>
{copiedLink
? <><Check className="size-4" /> Copied!</>
: <><Copy className="size-4" /> Copy invite link</>
}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ─── 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCheck className="size-4" /> Assign to Group
</DialogTitle>
<DialogDescription>
Select a group to assign{" "}
{userCount === 1 ? "this user" : `${userCount} users`} to.
They will be added to the selected group.
</DialogDescription>
</DialogHeader>
<Command shouldFilter={false} className="rounded-lg border shadow-sm">
{/* ── Search input ── */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups..."
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground py-0.5"
/>
{search && (
<button
onClick={() => setSearch('')}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* ── Group list ── */}
<CommandList>
<ScrollArea className="h-64">
{filtered.length === 0 ? (
<CommandEmpty className="py-8 text-sm text-center text-muted-foreground">
{search ? `No results for "${search}".` : 'No groups available.'}
</CommandEmpty>
) : (
<CommandGroup>
{filtered.map((g) => {
const isSelected = selectedGroupId === g.group_id;
return (
<CommandItem
key={g.group_id}
value={String(g.group_id)}
onSelect={() => 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 */}
<span className={cn(
"h-4 w-4 shrink-0 rounded border-2 flex items-center justify-center transition-all",
isSelected
? "bg-primary border-primary shadow-none ring-2 ring-primary/25 ring-offset-1"
: [
"bg-background border-border",
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.10),inset_0_1px_2px_rgba(0,0,0,0.06)]",
"hover:border-primary/60",
]
)}>
{isSelected && (
<Check className="h-2.5 w-2.5 text-primary-foreground stroke-[3.5]" />
)}
</span>
{/* Name */}
<span className={cn(
"flex-1 text-sm truncate",
isSelected ? "font-medium text-foreground" : "text-foreground/90"
)}>
{g.name}
</span>
{/* Group code pill */}
<span className={cn(
"font-mono text-[11px] px-1.5 py-0.5 rounded shrink-0",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
{g.group_code}
</span>
</CommandItem>
);
})}
</CommandGroup>
)}
</ScrollArea>
</CommandList>
</Command>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button
disabled={!selectedGroupId || loading}
onClick={() => onAssign(selectedGroupId)}
>
Assign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── 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: <House className="size-4" />, 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 (
<section className="bg-muted/60 min-h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full flex flex-col gap-4 pb-8">
{/* ── Group detail card ──────────────────────────────────────────── */}
<div className="bg-card border rounded-xl p-5 flex flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-medium leading-none">
{group?.name ?? "—"}
</h1>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
group?.is_active
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-600"
}`}
>
{group?.is_active ? "Active" : "Inactive"}
</span>
</div>
<p className="text-sm text-muted-foreground">
{group?.description ?? "—"}
</p>
</div>
{/* ── Generate invite link button — hidden for NOGRP (system default group) ── */}
{group?.group_code && group.group_code !== 'NOGRP' && (
<Button
size="sm"
variant="outline"
className="shrink-0 gap-1.5"
onClick={() => setInviteOpen(true)}
>
<QrCode className="size-4" />
Invite link
</Button>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "Group Code", value: group?.group_code ?? "—" },
{ label: "Members", value: pagination?.totalRecords ?? 0, icon: <Users className="size-3.5 text-muted-foreground" /> },
{ label: "Created", value: formattedCreated },
{ label: "Last Updated", value: formattedUpdated },
].map(({ label, value, icon }) => (
<div key={label} className="bg-muted rounded-lg px-3 py-2">
<span className="block text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
{label}
</span>
<span className="text-sm font-medium flex items-center gap-1.5">
{icon}{value}
</span>
</div>
))}
</div>
</div>
{/* ── Members table ──────────────────────────────────────────────── */}
<div className="w-full">
<DataTable
title="Members"
data={members}
columns={columns}
attributes={memberAttrs}
pagination={pagination}
setPagination={setPagination}
loading={loading}
onFetch={handleFetch}
onFetchFilterData={fetchUserFieldValues}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="member"
emptyMessage="No members in this group yet."
/>
</div>
</div>
</div>
{/* ── Invite link dialog ────────────────────────────────────────────── */}
<InviteLinkDialog
open={inviteOpen}
onOpenChange={setInviteOpen}
group={group}
/>
{/* ── Assign to group dialog (NOGRP members only) ───────────────────── */}
<AssignGroupDialog
open={assignOpen}
onOpenChange={setAssignOpen}
userCount={assignIds?.length ?? (assignTarget ? 1 : 0)}
groups={groups}
loading={loading}
onAssign={handleAssign}
/>
{/* ── Add member ───────────────────────────────────────────────────── */}
<AddSheet
open={addMemberOpen}
onOpenChange={setAddMemberOpen}
title="Add members"
submitLabel="Add"
users={usersNotIn}
loading={loading}
onFetch={() => 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 ─────────────────────────────────────────────────── */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !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 ───────────────────────────────────────────────────── */}
<ArchiveDialog
open={!!archiveIds}
onOpenChange={(v) => !v && setArchiveIds(null)}
ids={archiveIds ?? []}
entityLabel="Member"
onArchive={({ ids }) => removeUsersFromGroup(groupId, ids)}
loading={loading}
onSuccess={handleRemoveSuccess}
/>
</section>
);
}