Compare commits

2 Commits

Author SHA1 Message Date
a8d364be1f tags selector 2025-04-01 20:09:49 +08:00
326a6c6d63 project selector 2025-04-01 20:03:15 +08:00
3 changed files with 649 additions and 18 deletions

View File

@@ -4,6 +4,8 @@ import { useState } from 'react';
import { subDays } from 'date-fns'; import { subDays } from 'date-fns';
import { DateRangePicker } from '@/app/components/ui/DateRangePicker'; import { DateRangePicker } from '@/app/components/ui/DateRangePicker';
import { TeamSelector } from '@/app/components/ui/TeamSelector'; import { TeamSelector } from '@/app/components/ui/TeamSelector';
import { ProjectSelector } from '@/app/components/ui/ProjectSelector';
import { TagSelector } from '@/app/components/ui/TagSelector';
export default function AnalyticsPage() { export default function AnalyticsPage() {
// 默认日期范围为最近7天 // 默认日期范围为最近7天
@@ -15,6 +17,17 @@ export default function AnalyticsPage() {
// 添加团队选择状态 - 使用数组支持多选 // 添加团队选择状态 - 使用数组支持多选
const [selectedTeamIds, setSelectedTeamIds] = useState<string[]>([]); const [selectedTeamIds, setSelectedTeamIds] = useState<string[]>([]);
// 添加项目选择状态 - 使用数组支持多选
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
// 添加标签选择状态 - 使用数组支持多选
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
// 分析是否有任何选择
const hasNoSelection = selectedTeamIds.length === 0 &&
selectedProjectIds.length === 0 &&
selectedTagIds.length === 0;
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
@@ -28,6 +41,20 @@ export default function AnalyticsPage() {
className="w-[250px]" className="w-[250px]"
multiple={true} 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}
/>
<TagSelector
value={selectedTagIds}
onChange={(value) => setSelectedTagIds(Array.isArray(value) ? value : [value])}
className="w-[250px]"
multiple={true}
teamId={selectedTeamIds.length === 1 ? selectedTeamIds[0] : undefined}
/>
<DateRangePicker <DateRangePicker
value={dateRange} value={dateRange}
onChange={setDateRange} onChange={setDateRange}
@@ -35,31 +62,62 @@ export default function AnalyticsPage() {
</div> </div>
</div> </div>
{/* 如果没有选择团队,显示提示信息 */} {/* 如果没有选择任何项,显示提示信息 */}
{selectedTeamIds.length === 0 && ( {hasNoSelection && (
<div className="flex items-center justify-center p-8 bg-gray-50 rounded-lg"> <div className="flex items-center justify-center p-8 bg-gray-50 rounded-lg">
<p className="text-gray-500"> <p className="text-gray-500">
Please select one or more teams to view analytics Please select teams, projects, or tags to view analytics
</p> </p>
</div> </div>
)} )}
{/* 如果选择了团队,这里可以显示团队相关的分析数据 */} {/* 显示团队相关的分析数据 */}
{selectedTeamIds.length > 0 && ( {selectedTeamIds.length > 0 && (
<div className="space-y-6"> <div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="bg-white rounded-lg shadow p-6"> <h2 className="text-lg font-semibold text-gray-900 mb-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4"> Team Analytics ({selectedTeamIds.length} selected)
Analytics for {selectedTeamIds.length} selected {selectedTeamIds.length === 1 ? 'team' : 'teams'} </h2>
</h2> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> {selectedTeamIds.map((teamId) => (
{/* You can map through selectedTeamIds and display data for each team */} <div key={teamId} className="p-4 border rounded-md">
{selectedTeamIds.map((teamId) => ( <h3 className="font-medium text-gray-800">Team ID: {teamId}</h3>
<div key={teamId} className="p-4 border rounded-md"> <p className="text-gray-500 mt-2">Team analytics will appear here</p>
<h3 className="font-medium text-gray-800">Team ID: {teamId}</h3> </div>
<p className="text-gray-500 mt-2">Team analytics will appear here</p> ))}
</div> </div>
))} </div>
</div> )}
{/* 显示项目相关的分析数据 */}
{selectedProjectIds.length > 0 && (
<div className="bg-white rounded-lg shadow p-6 mb-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>
)}
{/* 显示标签相关的分析数据 */}
{selectedTagIds.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Tag Analytics ({selectedTagIds.length} selected)
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{selectedTagIds.map((tagId) => (
<div key={tagId} className="p-4 border rounded-md">
<h3 className="font-medium text-gray-800">Tag ID: {tagId}</h3>
<p className="text-gray-500 mt-2">Tag analytics will appear here</p>
</div>
))}
</div> </div>
</div> </div>
)} )}

View 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>
);
}

View File

@@ -0,0 +1,281 @@
"use client";
import * as React from 'react';
import { useEffect, useState, useRef } from 'react';
import { getSupabaseClient } from '../../utils/supabase';
import { AuthChangeEvent } from '@supabase/supabase-js';
import { Loader2, X, Check, Tag } from 'lucide-react';
import { cn } from '@/lib/utils';
// Define Tag type based on the database schema
interface Tag {
id: string;
name: string;
type?: string | null;
attributes?: Record<string, unknown>;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
parent_tag_id?: string | null;
team_id?: string | null;
is_shared?: boolean;
schema_version?: number | null;
is_system?: boolean;
}
// TagSelector component with multi-select support
export function TagSelector({
value,
onChange,
className,
multiple = false,
teamId,
tagType,
}: {
value?: string | string[];
onChange?: (tagId: string | string[]) => void;
className?: string;
multiple?: boolean;
teamId?: string; // Optional team ID to filter tags by team
tagType?: string; // Optional tag type for filtering
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tags, setTags] = useState<Tag[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isOpen, setIsOpen] = useState(false);
const selectorRef = useRef<HTMLDivElement>(null);
// Initialize selected tags 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 fetchTags = async () => {
if (!isMounted) return;
setLoading(true);
setError(null);
try {
const supabase = getSupabaseClient();
let query = supabase.from('tags').select('*').is('deleted_at', null);
// Filter by team if teamId is provided
if (teamId) {
query = query.eq('team_id', teamId);
}
// Filter by tag type if provided
if (tagType) {
query = query.eq('type', tagType);
}
const { data: tagsData, error: tagsError } = await query;
if (tagsError) throw tagsError;
if (!tagsData || tagsData.length === 0) {
if (isMounted) setTags([]);
return;
}
if (isMounted) {
setTags(tagsData as Tag[]);
}
} catch (err) {
if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to load tags');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
const supabase = getSupabaseClient();
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent) => {
if (event === 'SIGNED_IN') {
fetchTags();
} else if (event === 'SIGNED_OUT') {
setTags([]);
setError(null);
}
});
supabase.auth.getSession().then(() => {
fetchTags();
});
return () => {
isMounted = false;
subscription.unsubscribe();
};
}, [teamId, tagType]);
const handleToggle = () => {
if (!loading && !error && tags.length > 0) {
setIsOpen(!isOpen);
}
};
const handleTagSelect = (tagId: string) => {
let newSelected: string[];
if (multiple) {
// For multi-select: toggle tag in/out of selection
if (selectedIds.includes(tagId)) {
newSelected = selectedIds.filter(id => id !== tagId);
} else {
newSelected = [...selectedIds, tagId];
}
} else {
// For single-select: replace selection with the new tag
newSelected = [tagId];
setIsOpen(false);
}
setSelectedIds(newSelected);
if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || '');
}
};
const removeTag = (e: React.MouseEvent, tagId: string) => {
e.stopPropagation();
const newSelected = selectedIds.filter(id => id !== tagId);
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 (tags.length === 0) {
return (
<div className={cn(
"flex w-full items-center rounded-md border px-3 py-2 text-muted-foreground",
className
)}>
No tags available
</div>
);
}
const selectedTags = tags.filter(tag => selectedIds.includes(tag.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-purple-500",
className
)}
onClick={handleToggle}
>
{selectedTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedTags.map(tag => (
<div
key={tag.id}
className="flex items-center gap-1 bg-purple-100 text-purple-800 rounded-md px-2 py-1 text-sm"
>
{tag.name}
{multiple && (
<X
size={14}
className="cursor-pointer hover:text-purple-900"
onClick={(e) => removeTag(e, tag.id)}
/>
)}
</div>
))}
</div>
) : (
<div className="px-2 py-1 text-gray-500">Select tags</div>
)}
</div>
{isOpen && (
<div className="absolute w-full mt-1 max-h-60 overflow-auto rounded-md border bg-white shadow-lg z-10">
{tags.map(tag => (
<div
key={tag.id}
className={cn(
"px-3 py-2 cursor-pointer hover:bg-gray-100 flex items-center justify-between",
selectedIds.includes(tag.id) && "bg-purple-50"
)}
onClick={() => handleTagSelect(tag.id)}
>
<span className="flex items-center gap-2">
<Tag className="h-4 w-4 text-purple-500" />
<span>{tag.name}</span>
{tag.type && (
<span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
{tag.type}
</span>
)}
</span>
{selectedIds.includes(tag.id) && (
<Check className="h-4 w-4 text-purple-600" />
)}
</div>
))}
</div>
)}
</div>
);
}