Files
starr-philproperties/src/components/generic/Sheet/AddSheet.jsx
T
kennethobsequio bac7168b1e push
pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
2026-06-28 11:30:01 +08:00

243 lines
9.8 KiB
React

// components/generic/Sheet/AddUsersSheet.jsx
import { useState, useEffect, useMemo } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { AlertTriangle } from "lucide-react";
/**
* Generic sheet for selecting and adding users to any entity
* (groups, tasks, projects, etc.)
*
* @param {Object} props
* @param {boolean} props.open
* @param {Function} props.onOpenChange
*
* @param {string} [props.title] Sheet heading. Default: "Add members"
* @param {string} [props.submitLabel] Submit button label. Default: "Add"
*
* @param {Array} props.users [{ [idKey], [labelKey] }] — list to display
* @param {boolean} props.loading
* @param {Function} props.onFetch Called on open to (re)load the list
*
* @param {string} [props.idKey] Key for the user id. Default: "user_id"
* @param {string} [props.labelKey] Key for the display name. Default: "full_name"
* @param {string} [props.subLabelKey] Optional secondary line (e.g. "email")
* @param {string} [props.warningKey] If set, users with a truthy value at this key show
* a "will be moved" warning (e.g. "current_group")
*
* @param {Function} props.onSubmit Called with selected ids[]
*
* @example — groups
* <AddUsersSheet
* title="Add members"
* users={usersNotIn}
* onFetch={() => fetchUsersNotInGroup(gid)}
* onSubmit={(ids) => addUsersToGroup(gid, ids)}
* ...
* />
*
* @example — tasks
* <AddUsersSheet
* title="Assign users"
* users={unassignedUsers}
* onFetch={() => fetchUnassignedUsers(taskId)}
* onSubmit={(ids) => assignUsersToTask(taskId, ids)}
* idKey="user_id"
* labelKey="full_name"
* subLabelKey="email"
* ...
* />
*/
export function AddSheet({
open,
onOpenChange,
title = "Add members",
submitLabel = "Add",
users = [],
loading = false,
onFetch,
idKey = "user_id",
labelKey = "full_name",
subLabelKey = null,
warningKey = null,
onSubmit,
}) {
const [search, setSearch] = useState("");
const [selected, setSelected] = useState([]);
useEffect(() => {
if (open) {
onFetch?.();
setSearch("");
setSelected([]);
}
}, [open]);
const filtered = useMemo(() => {
if (!search.trim()) return users;
return users.filter((u) =>
String(u[labelKey] ?? "").toLowerCase().includes(search.toLowerCase())
);
}, [search, users, labelKey]);
const toggle = (id) =>
setSelected((prev) =>
prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]
);
const toggleAll = () => {
const allIds = filtered.map((u) => u[idKey]);
const allSelected = allIds.every((id) => selected.includes(id));
setSelected((prev) =>
allSelected
? prev.filter((id) => !allIds.includes(id))
: [...new Set([...prev, ...allIds])]
);
};
const allFilteredSelected =
filtered.length > 0 && filtered.every((u) => selected.includes(u[idKey]));
const movingCount = useMemo(() => {
if (!warningKey) return 0;
return selected.filter((id) => {
const user = users.find((u) => u[idKey] === id);
return !!user?.[warningKey];
}).length;
}, [selected, users, warningKey, idKey]);
async function handleSubmit() {
if (!selected.length) return;
await onSubmit(selected);
onOpenChange(false);
}
function handleClose() {
setSearch("");
setSelected([]);
onOpenChange(false);
}
return (
<Sheet open={open} onOpenChange={handleClose}>
{/*
SheetContent is a flex column with fixed height (100dvh).
We split it into 3 rows: header (shrink-0), body (flex-1 overflow-hidden), footer (shrink-0).
The body itself is a flex column — search and select-all shrink, list overflows.
*/}
<SheetContent side="right" className="w-[400px] flex flex-col h-full p-0">
{/* Header */}
<SheetHeader className="shrink-0 border-b px-6 py-4">
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
{/* Body — fills remaining space, clips overflow */}
<div className="flex-1 min-h-0 flex flex-col gap-3 px-6 py-4 relative">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm z-10">
<Spinner className="size-8" />
</div>
)}
{/* Search — fixed height */}
<div className="shrink-0">
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Select all — fixed height */}
{filtered.length > 0 && (
<label className="shrink-0 flex items-center gap-2 text-sm font-medium cursor-pointer select-none border-b pb-3">
<input
type="checkbox"
checked={allFilteredSelected}
onChange={toggleAll}
/>
Select all ({filtered.length})
</label>
)}
{/* Scrollable list — takes all remaining space */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-1 pr-1">
{!loading && filtered.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
{search ? `No results for "${search}".` : "No users available."}
</p>
) : (
filtered.map((user) => {
const id = user[idKey];
const label = user[labelKey];
const sub = subLabelKey ? user[subLabelKey] : null;
const warning = warningKey ? user[warningKey] : null;
return (
<label
key={id}
className="flex items-center gap-3 p-1 rounded-md hover:bg-muted cursor-pointer text-sm select-none"
>
<input
type="checkbox"
checked={selected.includes(id)}
onChange={() => toggle(id)}
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate">{label}</span>
{sub && (
<span className="text-xs text-muted-foreground truncate">
{sub}
</span>
)}
{warning && (
<span className="text-xs text-amber-600 flex items-center gap-1 mt-0.5">
<AlertTriangle className="size-3 shrink-0" />
Also in: {warning}
</span>
)}
</div>
</label>
);
})
)}
</div>
</div>
</div>
{/* Footer */}
<div className="shrink-0 flex flex-col gap-2 border-t px-6 py-4">
{movingCount > 0 && (
<p className="text-xs text-amber-600 flex items-center gap-1.5">
<AlertTriangle className="size-3 shrink-0" />
{movingCount} user{movingCount > 1 ? 's' : ''} already belong{movingCount === 1 ? 's' : ''} to other group{movingCount > 1 ? 's' : ''}.
</p>
)}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button
className="flex-1"
disabled={!selected.length || loading}
onClick={handleSubmit}
>
{selected.length > 0 ? `${submitLabel} (${selected.length})` : submitLabel}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
}