mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
243 lines
9.8 KiB
React
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>
|
|
);
|
|
} |