pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:30:01 +08:00
parent b03b204861
commit bac7168b1e
100 changed files with 5958 additions and 1976 deletions
+203 -17
View File
@@ -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 });