This commit is contained in:
rgrgogu
2026-05-20 13:26:38 +08:00
36 changed files with 3751 additions and 2 deletions
+122
View File
@@ -0,0 +1,122 @@
/***********************************************************************************************************************************************************************
* File Name : DeadlinePicker.jsx
* Type : Reusable Component
* Description : Combined date + time picker for deadline fields.
* Controlled via a single ISO datetime string (value / onChange).
* Date is picked via a Calendar popover; time via a plain time input.
*
* Props:
* value : string | null — ISO datetime string e.g. "2026-06-01T10:30"
* onChange : (iso: string | null) => void
* disabled?: boolean
***********************************************************************************************************************************************************************/
import { useState } from 'react';
import { format, parseISO, isValid } from 'date-fns';
import { ChevronDownIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
// ── Helpers ───────────────────────────────────────────────────────────────────
function toDate(iso) {
if (!iso) return undefined;
const d = parseISO(iso);
return isValid(d) ? d : undefined;
}
function toTimeString(iso) {
if (!iso) return '00:00';
const d = parseISO(iso);
if (!isValid(d)) return '00:00';
return format(d, 'HH:mm');
}
function buildISO(date, timeStr) {
if (!date) return null;
const [h = '00', m = '00'] = (timeStr ?? '00:00').split(':');
const d = new Date(date);
d.setHours(Number(h), Number(m), 0, 0);
return d.toISOString();
}
// ─────────────────────────────────────────────────────────────────────────────
export default function DeadlinePicker({ value, onChange, disabled = false }) {
const [open, setOpen] = useState(false);
const selectedDate = toDate(value);
const timeStr = toTimeString(value);
const handleDateSelect = (date) => {
onChange(buildISO(date, timeStr));
setOpen(false);
};
const handleTimeChange = (e) => {
onChange(buildISO(selectedDate ?? new Date(), e.target.value));
};
const handleClear = () => onChange(null);
return (
<div className="flex flex-wrap items-end gap-3">
{/* ── Date picker ──────────────────────────────────────────────── */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Date</Label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className="w-36 justify-between font-normal text-sm"
>
{selectedDate ? format(selectedDate, 'MMM d, yyyy') : 'Select date'}
<ChevronDownIcon className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
captionLayout="dropdown"
defaultMonth={selectedDate}
onSelect={handleDateSelect}
/>
</PopoverContent>
</Popover>
</div>
{/* ── Time input ───────────────────────────────────────────────── */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Time</Label>
<Input
type="time"
value={timeStr}
onChange={handleTimeChange}
disabled={disabled || !selectedDate}
step="60"
className="w-28 appearance-none bg-background
[&::-webkit-calendar-picker-indicator]:hidden
[&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
{/* ── Clear ────────────────────────────────────────────────────── */}
{value && (
<Button
type="button"
variant="outline"
disabled={disabled}
onClick={handleClear}
>
Clear
</Button>
)}
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
/***********************************************************************************************************************************************************************
* File Name : GroupMultiSelect.jsx
* Type : Reusable Component
* Description : Searchable multi-select dropdown for User Groups.
* - Portal-based dropdown (escapes Card overflow clipping)
* - First badge + "+N" overflow chip when 2+ selected
* - "Select all" and "Clear" actions in dropdown header
*
* Props:
* value : number[] — selected group_ids
* onChange : (ids: number[]) => void
* groups? : { group_id, name }[] — skip API fetch if provided
* disabled? : boolean
* placeholder?: string
***********************************************************************************************************************************************************************/
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import api from '@/utils/api.util';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { Check, ChevronsUpDown, X, Users } from 'lucide-react';
export default function GroupMultiSelect({
value = [],
onChange,
groups: groupsProp = null,
disabled = false,
placeholder = 'Select groups…',
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [allGroups, setAllGroups] = useState(groupsProp ?? []);
const [loadingGroups, setLoadingGroups] = useState(!groupsProp);
const [dropdownStyle, setDropdownStyle] = useState({});
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
// ── Fetch groups if not provided by parent ────────────────────────────────
useEffect(() => {
if (groupsProp !== null) {
setAllGroups(groupsProp);
setLoadingGroups(false);
return;
}
let cancelled = false;
setLoadingGroups(true);
api.get('/admin/groups', { params: { limit: 500 } })
.then((res) => {
if (!cancelled) {
const raw = res.data?.data?.data ?? res.data?.data ?? [];
setAllGroups(Array.isArray(raw) ? raw : []);
}
})
.catch(() => { if (!cancelled) setAllGroups([]); })
.finally(() => { if (!cancelled) setLoadingGroups(false); });
return () => { cancelled = true; };
}, [groupsProp]);
// ── Position portal dropdown under trigger ────────────────────────────────
useEffect(() => {
if (!open || !triggerRef.current) return;
const reposition = () => {
const rect = triggerRef.current.getBoundingClientRect();
setDropdownStyle({
position: 'fixed',
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
};
reposition();
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
return () => {
window.removeEventListener('scroll', reposition, true);
window.removeEventListener('resize', reposition);
};
}, [open]);
// ── Close on outside click ────────────────────────────────────────────────
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (
triggerRef.current?.contains(e.target) ||
dropdownRef.current?.contains(e.target)
) return;
setOpen(false);
setSearch('');
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// ── Helpers ───────────────────────────────────────────────────────────────
const filtered = allGroups.filter((g) =>
g.name.toLowerCase().includes(search.toLowerCase())
);
const selectedGroups = allGroups.filter((g) => value.includes(g.group_id));
const overflowCount = selectedGroups.length - 1;
// All currently visible (filtered) IDs — used for select-all scope
const filteredIds = filtered.map((g) => g.group_id);
const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id));
const toggle = (groupId) =>
onChange(value.includes(groupId)
? value.filter((id) => id !== groupId)
: [...value, groupId]
);
const remove = (e, groupId) => {
e.stopPropagation();
onChange(value.filter((id) => id !== groupId));
};
// Select all visible (filtered) groups
const handleSelectAll = () => {
const merged = Array.from(new Set([...value, ...filteredIds]));
onChange(merged);
};
// Clear all selections
const handleClear = () => onChange([]);
// ── Portal dropdown ───────────────────────────────────────────────────────
const dropdown = open && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="rounded-md border border-border bg-popover shadow-lg"
>
{/* Search row */}
<div className="p-2 border-b border-border">
<Input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups…"
className="h-8 text-sm"
/>
</div>
{/* Select all / Clear row — only when groups are loaded */}
{!loadingGroups && allGroups.length > 0 && (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/40">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={allFilteredSelected ? handleClear : handleSelectAll}
className="text-xs text-primary hover:underline underline-offset-2 font-medium"
>
{allFilteredSelected ? 'Deselect all' : 'Select all'}
{search && ` (${filteredIds.length})`}
</button>
{value.length > 0 && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
>
Clear ({value.length})
</button>
)}
</div>
)}
{/* Options */}
<ul className="max-h-52 overflow-y-auto py-1">
{loadingGroups ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
Loading groups…
</li>
) : filtered.length === 0 ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
No groups found.
</li>
) : (
filtered.map((g) => {
const selected = value.includes(g.group_id);
return (
<li
key={g.group_id}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggle(g.group_id)}
className={cn(
'flex items-center gap-2 px-3 py-2 text-sm cursor-pointer select-none',
'hover:bg-accent hover:text-accent-foreground',
selected && 'bg-accent/50'
)}
>
<div className={cn(
'h-4 w-4 rounded border flex items-center justify-center shrink-0',
selected
? 'bg-primary border-primary text-primary-foreground'
: 'border-input'
)}>
{selected && <Check className="h-3 w-3" />}
</div>
<Users className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{g.name}</span>
</li>
);
})
)}
</ul>
</div>,
document.body
);
return (
<>
{/* ── Trigger button ───────────────────────────────────────────── */}
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={() => { setOpen((o) => !o); setSearch(''); }}
className={cn(
'w-full min-h-9 px-3 py-1.5 rounded-md border border-input bg-background text-sm',
'flex items-center gap-1.5 text-left',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
'disabled:opacity-50 disabled:cursor-not-allowed',
open && 'ring-2 ring-ring ring-offset-1'
)}
>
{selectedGroups.length === 0 ? (
<span className="text-muted-foreground flex-1">{placeholder}</span>
) : (
<span className="flex items-center gap-1 flex-1 min-w-0">
{/* Always show only the first selected group */}
<Badge variant="secondary" className="gap-1 text-xs pr-1 shrink-0">
<Users className="h-3 w-3" />
{selectedGroups[0].name}
<span
role="button"
tabIndex={0}
onClick={(e) => remove(e, selectedGroups[0].group_id)}
onKeyDown={(e) => e.key === 'Enter' && remove(e, selectedGroups[0].group_id)}
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5 cursor-pointer"
>
<X className="h-2.5 w-2.5" />
</span>
</Badge>
{/* +N overflow chip */}
{overflowCount > 0 && (
<Badge variant="outline" className="text-xs px-1.5 shrink-0">
+{overflowCount}
</Badge>
)}
</span>
)}
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground shrink-0 ml-auto" />
</button>
{/* ── Portalled dropdown ────────────────────────────────────────── */}
{dropdown}
</>
);
}