project selector
This commit is contained in:
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { subDays } from 'date-fns';
|
||||
import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
|
||||
import { TeamSelector } from '@/app/components/ui/TeamSelector';
|
||||
import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
// 默认日期范围为最近7天
|
||||
@@ -16,6 +17,9 @@ export default function AnalyticsPage() {
|
||||
// 添加团队选择状态 - 使用数组支持多选
|
||||
const [selectedTeamIds, setSelectedTeamIds] = useState<string[]>([]);
|
||||
|
||||
// 添加项目选择状态 - 使用数组支持多选
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-8">
|
||||
@@ -28,6 +32,13 @@ export default function AnalyticsPage() {
|
||||
className="w-[250px]"
|
||||
multiple={true}
|
||||
/>
|
||||
<ProjectSelector
|
||||
value={selectedProjectIds}
|
||||
onChange={(value) => setSelectedProjectIds(Array.isArray(value) ? value : [value])}
|
||||
className="w-[250px]"
|
||||
multiple={true}
|
||||
teamId={selectedTeamIds.length === 1 ? selectedTeamIds[0] : undefined}
|
||||
/>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={setDateRange}
|
||||
@@ -35,31 +46,45 @@ export default function AnalyticsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 如果没有选择团队,显示提示信息 */}
|
||||
{selectedTeamIds.length === 0 && (
|
||||
{/* 如果没有选择团队或项目,显示提示信息 */}
|
||||
{selectedTeamIds.length === 0 && selectedProjectIds.length === 0 && (
|
||||
<div className="flex items-center justify-center p-8 bg-gray-50 rounded-lg">
|
||||
<p className="text-gray-500">
|
||||
Please select one or more teams to view analytics
|
||||
Please select teams or projects to view analytics
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 如果选择了团队,这里可以显示团队相关的分析数据 */}
|
||||
{/* 显示团队相关的分析数据 */}
|
||||
{selectedTeamIds.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Analytics for {selectedTeamIds.length} selected {selectedTeamIds.length === 1 ? 'team' : 'teams'}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* You can map through selectedTeamIds and display data for each team */}
|
||||
{selectedTeamIds.map((teamId) => (
|
||||
<div key={teamId} className="p-4 border rounded-md">
|
||||
<h3 className="font-medium text-gray-800">Team ID: {teamId}</h3>
|
||||
<p className="text-gray-500 mt-2">Team analytics will appear here</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Team Analytics ({selectedTeamIds.length} selected)
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{selectedTeamIds.map((teamId) => (
|
||||
<div key={teamId} className="p-4 border rounded-md">
|
||||
<h3 className="font-medium text-gray-800">Team ID: {teamId}</h3>
|
||||
<p className="text-gray-500 mt-2">Team analytics will appear here</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 显示项目相关的分析数据 */}
|
||||
{selectedProjectIds.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Project Analytics ({selectedProjectIds.length} selected)
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{selectedProjectIds.map((projectId) => (
|
||||
<div key={projectId} className="p-4 border rounded-md">
|
||||
<h3 className="font-medium text-gray-800">Project ID: {projectId}</h3>
|
||||
<p className="text-gray-500 mt-2">Project analytics will appear here</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
292
app/components/ui/ProjectSelector.tsx
Normal file
292
app/components/ui/ProjectSelector.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
"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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// If a teamId is provided, fetch projects for that team
|
||||
projectsQuery = supabase
|
||||
.from('team_projects')
|
||||
.select('project_id, projects:project_id(*)')
|
||||
.eq('team_id', teamId)
|
||||
.is('projects.deleted_at', null);
|
||||
} else {
|
||||
// Otherwise, fetch projects the user is a member of
|
||||
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;
|
||||
}
|
||||
|
||||
// Extract the project data from the query results
|
||||
if (isMounted && projectsData && projectsData.length > 0) {
|
||||
const projectList: Project[] = [];
|
||||
|
||||
for (const item of projectsData) {
|
||||
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
|
||||
projectList.push(item.projects as 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.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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user