This commit is contained in:
rgrgogu
2026-05-09 22:17:33 +08:00
parent ca6eae1d56
commit 1d94f27e61
28 changed files with 1863 additions and 220 deletions
+216
View File
@@ -0,0 +1,216 @@
// 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";
/**
* 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 {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,
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]));
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;
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">
<span className="truncate">{label}</span>
{sub && (
<span className="text-xs text-muted-foreground truncate">
{sub}
</span>
)}
</div>
</label>
);
})
)}
</div>
</div>
</div>
{/* Footer */}
<div className="shrink-0 flex gap-2 border-t px-6 py-4">
<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>
</SheetContent>
</Sheet>
);
}