347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import * as React from 'react';
|
||
import { useEffect, useState, useRef } from 'react';
|
||
import { getSupabaseClient } from '../../utils/supabase';
|
||
import { AuthChangeEvent, Session } from '@supabase/supabase-js';
|
||
import { Loader2, X, Check } from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
// Define our own Project type
|
||
interface Project {
|
||
id: string;
|
||
name: string;
|
||
description?: string | null;
|
||
attributes?: Record<string, unknown>;
|
||
created_at?: string;
|
||
updated_at?: string;
|
||
deleted_at?: string | null;
|
||
schema_version?: number | null;
|
||
creator_id?: string | null;
|
||
team_name?: string;
|
||
}
|
||
|
||
// 添加需要的类型定义
|
||
interface ProjectWithTeam {
|
||
project_id: string;
|
||
projects: Project;
|
||
teams?: { name: string };
|
||
}
|
||
|
||
// ProjectSelector component with multi-select support
|
||
export function ProjectSelector({
|
||
value,
|
||
onChange,
|
||
className,
|
||
multiple = false,
|
||
teamId,
|
||
}: {
|
||
value?: string | string[];
|
||
onChange?: (projectId: string | string[]) => void;
|
||
className?: string;
|
||
multiple?: boolean;
|
||
teamId?: string; // Optional team ID to filter projects by team
|
||
}) {
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [projects, setProjects] = useState<Project[]>([]);
|
||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const selectorRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Initialize selected projects based on value prop
|
||
useEffect(() => {
|
||
if (value) {
|
||
if (Array.isArray(value)) {
|
||
setSelectedIds(value);
|
||
} else {
|
||
setSelectedIds(value ? [value] : []);
|
||
}
|
||
} else {
|
||
setSelectedIds([]);
|
||
}
|
||
}, [value]);
|
||
|
||
// Add click outside listener to close dropdown
|
||
useEffect(() => {
|
||
function handleClickOutside(event: MouseEvent) {
|
||
if (selectorRef.current && !selectorRef.current.contains(event.target as Node)) {
|
||
setIsOpen(false);
|
||
}
|
||
}
|
||
|
||
// Only add the event listener if the dropdown is open
|
||
if (isOpen) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
return () => {
|
||
document.removeEventListener('mousedown', handleClickOutside);
|
||
};
|
||
}
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
let isMounted = true;
|
||
|
||
const fetchProjects = async (userId: string) => {
|
||
if (!isMounted) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const supabase = getSupabaseClient();
|
||
|
||
let projectsQuery;
|
||
|
||
if (teamId) {
|
||
// 如果提供了teamId,获取该团队的项目
|
||
projectsQuery = supabase
|
||
.from('team_projects')
|
||
.select('project_id, projects:project_id(*), teams:team_id(name)')
|
||
.eq('team_id', teamId)
|
||
.is('projects.deleted_at', null);
|
||
} else {
|
||
// 否则,获取用户所属的所有项目及其所属团队
|
||
projectsQuery = supabase
|
||
.from('user_projects')
|
||
.select('project_id, projects:project_id(*)')
|
||
.eq('user_id', userId)
|
||
.is('projects.deleted_at', null);
|
||
}
|
||
|
||
const { data: projectsData, error: projectsError } = await projectsQuery;
|
||
|
||
if (projectsError) throw projectsError;
|
||
|
||
if (!projectsData || projectsData.length === 0) {
|
||
if (isMounted) setProjects([]);
|
||
return;
|
||
}
|
||
|
||
// 如果没有提供teamId,需要单独获取每个项目对应的团队信息
|
||
if (!teamId && projectsData.length > 0) {
|
||
const projectIds = projectsData.map(item => item.project_id);
|
||
|
||
// 获取项目所属的团队信息
|
||
const { data: teamProjectsData, error: teamProjectsError } = await supabase
|
||
.from('team_projects')
|
||
.select('project_id, teams:team_id(name)')
|
||
.in('project_id', projectIds);
|
||
|
||
if (teamProjectsError) throw teamProjectsError;
|
||
|
||
// 创建项目ID到团队名称的映射
|
||
const projectTeamMap: Record<string, string> = {};
|
||
if (teamProjectsData) {
|
||
teamProjectsData.forEach(item => {
|
||
if (item.teams && typeof item.teams === 'object' && 'name' in item.teams) {
|
||
projectTeamMap[item.project_id] = (item.teams as { name: string }).name;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 提取项目数据,并添加团队名称
|
||
if (isMounted && projectsData) {
|
||
const projectList: Project[] = [];
|
||
|
||
for (const item of projectsData) {
|
||
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
|
||
const project = item.projects as Project;
|
||
project.team_name = projectTeamMap[project.id];
|
||
projectList.push(project);
|
||
}
|
||
}
|
||
|
||
setProjects(projectList);
|
||
}
|
||
} else {
|
||
// 如果提供了teamId,直接从查询结果中提取项目和团队信息
|
||
if (isMounted && projectsData) {
|
||
const projectList: Project[] = [];
|
||
|
||
for (const item of projectsData as ProjectWithTeam[]) {
|
||
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
|
||
const project = item.projects as Project;
|
||
if (item.teams && 'name' in item.teams) {
|
||
project.team_name = item.teams.name;
|
||
}
|
||
projectList.push(project);
|
||
}
|
||
}
|
||
|
||
setProjects(projectList);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (isMounted) {
|
||
setError(err instanceof Error ? err.message : 'Failed to load projects');
|
||
}
|
||
} finally {
|
||
if (isMounted) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
const supabase = getSupabaseClient();
|
||
|
||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
|
||
if (event === 'SIGNED_IN' && session?.user?.id) {
|
||
fetchProjects(session.user.id);
|
||
} else if (event === 'SIGNED_OUT') {
|
||
setProjects([]);
|
||
setError(null);
|
||
}
|
||
});
|
||
|
||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||
if (session?.user?.id) {
|
||
fetchProjects(session.user.id);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
isMounted = false;
|
||
subscription.unsubscribe();
|
||
};
|
||
}, [teamId]);
|
||
|
||
const handleToggle = () => {
|
||
if (!loading && !error && projects.length > 0) {
|
||
setIsOpen(!isOpen);
|
||
}
|
||
};
|
||
|
||
const handleProjectSelect = (projectId: string) => {
|
||
let newSelected: string[];
|
||
|
||
if (multiple) {
|
||
// For multi-select: toggle project in/out of selection
|
||
if (selectedIds.includes(projectId)) {
|
||
newSelected = selectedIds.filter(id => id !== projectId);
|
||
} else {
|
||
newSelected = [...selectedIds, projectId];
|
||
}
|
||
} else {
|
||
// For single-select: replace selection with the new project
|
||
newSelected = [projectId];
|
||
setIsOpen(false);
|
||
}
|
||
|
||
setSelectedIds(newSelected);
|
||
|
||
if (onChange) {
|
||
onChange(multiple ? newSelected : newSelected[0] || '');
|
||
}
|
||
};
|
||
|
||
const removeProject = (e: React.MouseEvent, projectId: string) => {
|
||
e.stopPropagation();
|
||
const newSelected = selectedIds.filter(id => id !== projectId);
|
||
setSelectedIds(newSelected);
|
||
if (onChange) {
|
||
onChange(multiple ? newSelected : newSelected[0] || '');
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className={cn(
|
||
"flex w-full items-center justify-between rounded-md border px-3 py-2",
|
||
className
|
||
)}>
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className={cn(
|
||
"flex w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 py-2 text-destructive",
|
||
className
|
||
)}>
|
||
{error}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (projects.length === 0) {
|
||
return (
|
||
<div className={cn(
|
||
"flex w-full items-center rounded-md border px-3 py-2 text-muted-foreground",
|
||
className
|
||
)}>
|
||
No projects available
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const selectedProjects = projects.filter(project => selectedIds.includes(project.id));
|
||
|
||
return (
|
||
<div className="relative" ref={selectorRef}>
|
||
<div
|
||
className={cn(
|
||
"flex w-full min-h-10 items-center flex-wrap rounded-md border p-1 cursor-pointer",
|
||
isOpen && "ring-2 ring-offset-2 ring-blue-500",
|
||
className
|
||
)}
|
||
onClick={handleToggle}
|
||
>
|
||
{selectedProjects.length > 0 ? (
|
||
<div className="flex flex-wrap gap-1">
|
||
{selectedProjects.map(project => (
|
||
<div
|
||
key={project.id}
|
||
className="flex items-center gap-1 bg-green-100 text-green-800 rounded-md px-2 py-1 text-sm"
|
||
>
|
||
{project.name}
|
||
{multiple && (
|
||
<X
|
||
size={14}
|
||
className="cursor-pointer hover:text-green-900"
|
||
onClick={(e) => removeProject(e, project.id)}
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="px-2 py-1 text-gray-500">Select a project</div>
|
||
)}
|
||
</div>
|
||
|
||
{isOpen && (
|
||
<div className="absolute w-full mt-1 max-h-60 overflow-auto rounded-md border bg-white shadow-lg z-10">
|
||
{projects.map(project => (
|
||
<div
|
||
key={project.id}
|
||
className={cn(
|
||
"px-3 py-2 cursor-pointer hover:bg-gray-100 flex items-center justify-between",
|
||
selectedIds.includes(project.id) && "bg-green-50"
|
||
)}
|
||
onClick={() => handleProjectSelect(project.id)}
|
||
>
|
||
<span className="flex flex-col">
|
||
<span className="font-medium">{project.name}</span>
|
||
{project.team_name && (
|
||
<span className="text-xs text-gray-500">
|
||
{project.team_name}
|
||
</span>
|
||
)}
|
||
{project.description && (
|
||
<span className="text-xs text-gray-500 truncate max-w-[250px]">
|
||
{project.description}
|
||
</span>
|
||
)}
|
||
</span>
|
||
{selectedIds.includes(project.id) && (
|
||
<Check className="h-4 w-4 text-green-600" />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|