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>
287 lines
9.1 KiB
TypeScript
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>
|
|
);
|
|
}
|