mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
565 lines
21 KiB
React
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>
|
|
);
|
|
} |