UMBRA/frontend/src/components/projects/AssignmentPicker.tsx
Kyle Pope bef856fd15 Add collaborative project sharing, task assignments, and delta polling
Enables multi-user project collaboration mirroring the shared calendar
pattern. Includes ProjectMember model with permission levels, task
assignment with auto-membership, optimistic locking, field allowlist
for assignees, disconnect cascade, delta polling for projects and
calendars, and full frontend integration with share sheet, assignment
picker, permission gating, and notification handling.

Migrations: 057 (indexes + version + comment user_id), 058
(project_members), 059 (project_task_assignments)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 03:18:35 +08:00

287 lines
9.1 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { ChevronDown, UserCircle, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TaskAssignment, ProjectMember } from "@/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function initials(name: string | null | undefined): string {
if (!name) return "?";
return name
.split(" ")
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? "")
.join("");
}
interface AvatarCircleProps {
name: string | null | undefined;
className?: string;
}
function AvatarCircle({ name, className }: AvatarCircleProps) {
return (
<span
className={cn(
"w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0",
className
)}
aria-hidden="true"
>
<span className="text-[10px] font-medium text-muted-foreground leading-none">
{initials(name)}
</span>
</span>
);
}
function roleBadge(
userId: number,
ownerId: number,
permission: ProjectMember["permission"]
): { label: string; className: string } {
if (userId === ownerId)
return { label: "Owner", className: "text-accent" };
if (permission === "create_modify")
return { label: "Editor", className: "text-muted-foreground" };
return { label: "Viewer", className: "text-muted-foreground/60" };
}
// ---------------------------------------------------------------------------
// AssigneeAvatars — stacked display for TaskRow / KanbanBoard
// ---------------------------------------------------------------------------
export function AssigneeAvatars({
assignments,
max = 3,
}: {
assignments: TaskAssignment[];
max?: number;
}) {
const visible = assignments.slice(0, max);
const overflow = assignments.length - max;
const allNames = assignments.map((a) => a.user_name ?? "Unknown").join(", ");
return (
<span
className="flex items-center"
title={allNames}
aria-label={`Assigned to: ${allNames}`}
>
{visible.map((a) => (
<span
key={a.user_id}
className="w-5 h-5 -ml-1.5 first:ml-0 rounded-full bg-muted border border-background flex items-center justify-center shrink-0"
>
<span className="text-[9px] font-medium text-muted-foreground leading-none">
{initials(a.user_name)}
</span>
</span>
))}
{overflow > 0 && (
<span className="w-5 h-5 -ml-1.5 rounded-full bg-muted border border-background flex items-center justify-center shrink-0">
<span className="text-[9px] font-medium text-muted-foreground leading-none">
+{overflow}
</span>
</span>
)}
</span>
);
}
// ---------------------------------------------------------------------------
// AssignmentPicker
// ---------------------------------------------------------------------------
interface AssignmentPickerProps {
currentAssignments: TaskAssignment[];
members: ProjectMember[];
currentUserId: number;
ownerId: number;
onAssign: (userIds: number[]) => void;
onUnassign: (userId: number) => void;
disabled?: boolean;
}
export function AssignmentPicker({
currentAssignments,
members,
currentUserId,
ownerId,
onAssign,
onUnassign,
disabled = false,
}: AssignmentPickerProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
if (!open) return;
function handle(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [open]);
const assignedIds = new Set(currentAssignments.map((a) => a.user_id));
// Build ordered member list: current user first, then rest alphabetically
const sortedMembers = [...members].sort((a, b) => {
if (a.user_id === currentUserId) return -1;
if (b.user_id === currentUserId) return 1;
return (a.user_name ?? "").localeCompare(b.user_name ?? "");
});
const availableMembers = sortedMembers.filter(
(m) => !assignedIds.has(m.user_id)
);
function handleSelect(userId: number) {
onAssign([userId]);
setOpen(false);
}
function handleRemove(e: React.MouseEvent, userId: number) {
e.stopPropagation();
onUnassign(userId);
}
function handleTriggerClick() {
if (disabled) return;
setOpen((prev) => !prev);
}
function handleTriggerKeyDown(e: React.KeyboardEvent) {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen((prev) => !prev);
}
if (e.key === "Escape") setOpen(false);
}
const hasAssignees = currentAssignments.length > 0;
return (
<div ref={containerRef} className="relative">
{/* Trigger area */}
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-haspopup="listbox"
aria-expanded={open}
aria-disabled={disabled}
onClick={handleTriggerClick}
onKeyDown={handleTriggerKeyDown}
className={cn(
"flex flex-wrap items-center gap-1 min-h-[28px] px-1.5 py-1 rounded-md",
"border border-transparent transition-colors",
disabled
? "opacity-50 cursor-not-allowed"
: "cursor-pointer hover:border-border hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
)}
>
{hasAssignees ? (
<>
{currentAssignments.map((a) => (
<span
key={a.user_id}
className="flex items-center gap-1 bg-secondary rounded-full pl-1 pr-1.5 py-0.5"
>
<AvatarCircle name={a.user_name} />
<span className="text-xs text-secondary-foreground leading-none max-w-[80px] truncate">
{a.user_id === currentUserId ? "Me" : (a.user_name ?? "Unknown")}
</span>
{!disabled && (
<button
type="button"
aria-label={`Remove ${a.user_name ?? "user"}`}
onClick={(e) => handleRemove(e, a.user_id)}
className="text-muted-foreground hover:text-foreground transition-colors ml-0.5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring rounded-full"
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
{!disabled && availableMembers.length > 0 && (
<ChevronDown
className={cn(
"w-3.5 h-3.5 text-muted-foreground/60 transition-transform ml-0.5 shrink-0",
open && "rotate-180"
)}
aria-hidden="true"
/>
)}
</>
) : (
<span className="flex items-center gap-1.5 text-muted-foreground/50 text-xs select-none">
<UserCircle className="w-4 h-4 shrink-0" aria-hidden="true" />
Assign...
<ChevronDown
className={cn(
"w-3.5 h-3.5 transition-transform",
open && "rotate-180"
)}
aria-hidden="true"
/>
</span>
)}
</div>
{/* Dropdown */}
{open && availableMembers.length > 0 && (
<ul
role="listbox"
aria-label="Available members"
className="border border-border bg-card shadow-lg rounded-lg absolute z-50 top-full left-0 mt-1 min-w-[180px] max-w-[240px] py-1 overflow-hidden"
>
{availableMembers.map((m) => {
const badge = roleBadge(m.user_id, ownerId, m.permission);
const displayName =
m.user_id === currentUserId
? "Me"
: (m.user_name ?? "Unknown");
return (
<li key={m.user_id}>
<button
type="button"
role="option"
aria-selected={false}
onClick={() => handleSelect(m.user_id)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-muted/50 transition-colors text-left focus-visible:outline-none focus-visible:bg-muted/50"
>
<AvatarCircle name={m.user_name} />
<span className="flex-1 truncate text-foreground">
{displayName}
</span>
<span className={cn("text-[11px] shrink-0", badge.className)}>
{badge.label}
</span>
</button>
</li>
);
})}
</ul>
)}
{/* Empty state when all members assigned */}
{open && availableMembers.length === 0 && (
<div className="border border-border bg-card shadow-lg rounded-lg absolute z-50 top-full left-0 mt-1 min-w-[180px] py-2 px-3">
<p className="text-xs text-muted-foreground">All members assigned</p>
</div>
)}
</div>
);
}