mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user