mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useMemo, useState, useEffect, useCallback } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { House, Users, QrCode, Download, Copy, Check, Link } from "lucide-react";
|
||||
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";
|
||||
@@ -16,9 +16,21 @@ import {
|
||||
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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||
|
||||
import { buildDataColumns, columnPinning } from "../../config/user_groups/view/columns.config";
|
||||
import { buildToolbarActions } from "../../config/user_groups/view/toolbar.config";
|
||||
@@ -118,6 +130,142 @@ function InviteLinkDialog({ open, onOpenChange, group }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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();
|
||||
@@ -135,13 +283,19 @@ export default function ViewGroup() {
|
||||
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,
|
||||
fetchGroupFieldValues,
|
||||
fetchUsersNotInGroup,
|
||||
@@ -149,6 +303,8 @@ export default function ViewGroup() {
|
||||
removeUsersFromGroup,
|
||||
} = useUserGroups();
|
||||
|
||||
const isNoGroup = group?.group_code === 'NOGRP';
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupId) return;
|
||||
fetchGroup(groupId).then((res) => {
|
||||
@@ -169,7 +325,9 @@ export default function ViewGroup() {
|
||||
};
|
||||
|
||||
const rowActions = buildRowActions({
|
||||
onRemove: (row) => setArchiveTarget(row),
|
||||
onRemove: (row) => setArchiveTarget(row),
|
||||
onAssign: (row) => openAssignDialog(row),
|
||||
isNoGroup,
|
||||
});
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
@@ -184,13 +342,15 @@ export default function ViewGroup() {
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
onRemoveMember: (row) => setArchiveTarget(row),
|
||||
onRemoveMembers: (ids) => setArchiveIds(ids),
|
||||
onRemoveMember: (row) => setArchiveTarget(row),
|
||||
onRemoveMembers: (ids) => setArchiveIds(ids),
|
||||
onAssignMembers: (ids) => openAssignDialog(ids),
|
||||
isNoGroup,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(memberAttrs, rowActions),
|
||||
[memberAttrs],
|
||||
[memberAttrs, isNoGroup],
|
||||
);
|
||||
|
||||
const handleRemoveSuccess = () => {
|
||||
@@ -200,23 +360,38 @@ export default function ViewGroup() {
|
||||
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 formattedCreated = group?.createdAt
|
||||
? new Date(group.createdAt).toLocaleDateString("en-PH", {
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
})
|
||||
: "—";
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
const formattedUpdated = group?.updatedAt
|
||||
? new Date(group.updatedAt).toLocaleDateString("en-PH", {
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
})
|
||||
: "—";
|
||||
const formattedCreated = group?.createdAt ? fmtDate(group.createdAt) : "—";
|
||||
const formattedUpdated = group?.updatedAt ? fmtDate(group.updatedAt) : "—";
|
||||
|
||||
const handleFetch = useCallback(
|
||||
(params) => fetchGroup(groupId, params),
|
||||
@@ -256,8 +431,8 @@ export default function ViewGroup() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Generate invite link button ── */}
|
||||
{group?.group_code && (
|
||||
{/* ── Generate invite link button — hidden for NOGRP (system default group) ── */}
|
||||
{group?.group_code && group.group_code !== 'NOGRP' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -330,6 +505,16 @@ export default function ViewGroup() {
|
||||
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}
|
||||
@@ -341,6 +526,7 @@ export default function ViewGroup() {
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user