diff --git a/CHANGELOG.md b/CHANGELOG.md index af0d3fc..5b5930c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.1.7] - 2025-08-10 + +### Fixed + +- **[進階篩選設定] Multi_select 欄位篩選無法生成圖表錯誤修復** ([#23](https://github.com/SteveLin100132/notion-chart-generator/issues/23)) + + - 修正 `multi_select` 類型屬性在進階篩選中使用「包含」和「不包含」操作符時產生的 Notion API 驗證錯誤 + - 問題根因:Notion API 要求 `multi_select.contains` 必須是字串值,但前端傳送陣列值 `["前端","後端"]` + - 解決方案: + - 更新 `FilterCondition` 介面,支援陣列值類型 (`string | number | boolean | string[]`) + - 改進 `convertToNotionFilter` 函數中的轉換邏輯,將 `multi_select` 的陣列值正確轉換為多個 `contains` 條件的 `or` 組合 + - 區分 `select` 和 `multi_select` 的操作符:`select` 使用 `equals`,`multi_select` 使用 `contains` + - 添加完整的錯誤處理和邊界情況檢查 + - 修復檔案: + - `frontend/src/components/query-builder.tsx` - 核心轉換邏輯修復 + - `frontend/src/lib/filter-validation.ts` - 類型定義更新 + - 現在 `multi_select` 欄位的進階篩選可正常生成圖表,不再出現 API 驗證錯誤 + ## [1.1.6] - 2025-08-09 ### Fixed diff --git a/frontend/src/components/query-builder.tsx b/frontend/src/components/query-builder.tsx index 4aae5ee..48f6cdd 100644 --- a/frontend/src/components/query-builder.tsx +++ b/frontend/src/components/query-builder.tsx @@ -1,184 +1,384 @@ -'use client' +"use client"; -import React, { useState, useCallback } from 'react' -import { Button } from '@/components/ui/button' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import React, { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { MultiSelectDropdown } from "@/components/ui/multi-select-dropdown"; -import { Input } from '@/components/ui/input' -import { DatePicker } from '@/components/ui/date-picker' -import { DatabaseProperty } from '@/lib/store' -import { Plus, X, Trash2 } from 'lucide-react' +import { Input } from "@/components/ui/input"; +import { DatePicker } from "@/components/ui/date-picker"; +import { DatabaseProperty } from "@/lib/store"; +import { Plus, X, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { getAllFilterErrors } from '@/lib/filter-validation' +import { getAllFilterErrors } from "@/lib/filter-validation"; // 重新導出驗證函數以保持向後兼容 -export { getAllFilterErrors, hasFilterErrors, validateFilterCondition } from '@/lib/filter-validation' +export { + getAllFilterErrors, + hasFilterErrors, + validateFilterCondition, +} from "@/lib/filter-validation"; // Notion 顏色對應表,與 Notion 的實際顏色保持一致 const NOTION_COLORS: { [key: string]: string } = { // 基本顏色 - 根據 Notion 官方顏色調色板 - default: '#f2f1ef', - gray: '#9B9A97', - brown: '#64473A', - orange: '#D9730D', - yellow: '#DFAB01', - green: '#0F7B6C', - blue: '#0B6E99', - purple: '#6940A5', - pink: '#AD1A72', - red: '#E03E3E', - + default: "#f2f1ef", + gray: "#9B9A97", + brown: "#64473A", + orange: "#D9730D", + yellow: "#DFAB01", + green: "#0F7B6C", + blue: "#0B6E99", + purple: "#6940A5", + pink: "#AD1A72", + red: "#E03E3E", + // 中文顏色名稱映射 - '灰色': '#9B9A97', - '棕色': '#64473A', - '橙色': '#D9730D', - '黃色': '#DFAB01', - '綠色': '#0F7B6C', - '藍色': '#0B6E99', - '紫色': '#6940A5', - '粉色': '#AD1A72', - '紅色': '#E03E3E', - + 灰色: "#9B9A97", + 棕色: "#64473A", + 橙色: "#D9730D", + 黃色: "#DFAB01", + 綠色: "#0F7B6C", + 藍色: "#0B6E99", + 紫色: "#6940A5", + 粉色: "#AD1A72", + 紅色: "#E03E3E", + // 特殊狀態顏色(通常用於 status 屬性) - 'Not started': '#9B9A97', - 'In progress': '#0B6E99', - 'Done': '#0F7B6C', - 'Canceled': '#AD1A72', - '未開始': '#9B9A97', - '進行中': '#0B6E99', - '已完成': '#0F7B6C', - '已取消': '#AD1A72', -} + "Not started": "#9B9A97", + "In progress": "#0B6E99", + Done: "#0F7B6C", + Canceled: "#AD1A72", + 未開始: "#9B9A97", + 進行中: "#0B6E99", + 已完成: "#0F7B6C", + 已取消: "#AD1A72", +}; // 取得 Notion 顏色的 CSS 值 const getNotionColor = (colorName: string): string => { - if (!colorName) return NOTION_COLORS.default - return NOTION_COLORS[colorName] || NOTION_COLORS.default -} + if (!colorName) return NOTION_COLORS.default; + return NOTION_COLORS[colorName] || NOTION_COLORS.default; +}; // 條件運算符定義 export interface OperatorConfig { - value: string - label: string - hasValue: boolean - allowedTypes: string[] + value: string; + label: string; + hasValue: boolean; + allowedTypes: string[]; } // 針對不同屬性類型的運算符配置 const OPERATORS: { [key: string]: OperatorConfig[] } = { // 數字類型運算符 number: [ - { value: 'equals', label: '等於 (=)', hasValue: true, allowedTypes: ['number'] }, - { value: 'does_not_equal', label: '不等於 (≠)', hasValue: true, allowedTypes: ['number'] }, - { value: 'greater_than', label: '大於 (>)', hasValue: true, allowedTypes: ['number'] }, - { value: 'less_than', label: '小於 (<)', hasValue: true, allowedTypes: ['number'] }, - { value: 'greater_than_or_equal_to', label: '大於等於 (≥)', hasValue: true, allowedTypes: ['number'] }, - { value: 'less_than_or_equal_to', label: '小於等於 (≤)', hasValue: true, allowedTypes: ['number'] }, - { value: 'is_empty', label: '為空', hasValue: false, allowedTypes: ['number'] }, - { value: 'is_not_empty', label: '不為空', hasValue: false, allowedTypes: ['number'] }, + { + value: "equals", + label: "等於 (=)", + hasValue: true, + allowedTypes: ["number"], + }, + { + value: "does_not_equal", + label: "不等於 (≠)", + hasValue: true, + allowedTypes: ["number"], + }, + { + value: "greater_than", + label: "大於 (>)", + hasValue: true, + allowedTypes: ["number"], + }, + { + value: "less_than", + label: "小於 (<)", + hasValue: true, + allowedTypes: ["number"], + }, + { + value: "greater_than_or_equal_to", + label: "大於等於 (≥)", + hasValue: true, + allowedTypes: ["number"], + }, + { + value: "less_than_or_equal_to", + label: "小於等於 (≤)", + hasValue: true, + allowedTypes: ["number"], + }, + { + value: "is_empty", + label: "為空", + hasValue: false, + allowedTypes: ["number"], + }, + { + value: "is_not_empty", + label: "不為空", + hasValue: false, + allowedTypes: ["number"], + }, ], - + // 文字類型運算符 text: [ - { value: 'equals', label: '等於', hasValue: true, allowedTypes: ['text'] }, - { value: 'does_not_equal', label: '不等於', hasValue: true, allowedTypes: ['text'] }, - { value: 'contains', label: '包含', hasValue: true, allowedTypes: ['text'] }, - { value: 'does_not_contain', label: '不包含', hasValue: true, allowedTypes: ['text'] }, - { value: 'starts_with', label: '開始於', hasValue: true, allowedTypes: ['text'] }, - { value: 'ends_with', label: '結束於', hasValue: true, allowedTypes: ['text'] }, - { value: 'is_empty', label: '為空', hasValue: false, allowedTypes: ['text'] }, - { value: 'is_not_empty', label: '不為空', hasValue: false, allowedTypes: ['text'] }, + { value: "equals", label: "等於", hasValue: true, allowedTypes: ["text"] }, + { + value: "does_not_equal", + label: "不等於", + hasValue: true, + allowedTypes: ["text"], + }, + { + value: "contains", + label: "包含", + hasValue: true, + allowedTypes: ["text"], + }, + { + value: "does_not_contain", + label: "不包含", + hasValue: true, + allowedTypes: ["text"], + }, + { + value: "starts_with", + label: "開始於", + hasValue: true, + allowedTypes: ["text"], + }, + { + value: "ends_with", + label: "結束於", + hasValue: true, + allowedTypes: ["text"], + }, + { + value: "is_empty", + label: "為空", + hasValue: false, + allowedTypes: ["text"], + }, + { + value: "is_not_empty", + label: "不為空", + hasValue: false, + allowedTypes: ["text"], + }, ], // 選擇類型運算符 select: [ - { value: 'equals', label: '等於', hasValue: true, allowedTypes: ['select'] }, - { value: 'does_not_equal', label: '不等於', hasValue: true, allowedTypes: ['select'] }, - { value: 'contains', label: '包含', hasValue: true, allowedTypes: ['select'] }, - { value: 'does_not_contain', label: '不包含', hasValue: true, allowedTypes: ['select'] }, - { value: 'is_empty', label: '為空', hasValue: false, allowedTypes: ['select'] }, - { value: 'is_not_empty', label: '不為空', hasValue: false, allowedTypes: ['select'] }, + { + value: "equals", + label: "等於", + hasValue: true, + allowedTypes: ["select"], + }, + { + value: "does_not_equal", + label: "不等於", + hasValue: true, + allowedTypes: ["select"], + }, + { + value: "contains", + label: "包含", + hasValue: true, + allowedTypes: ["select"], + }, + { + value: "does_not_contain", + label: "不包含", + hasValue: true, + allowedTypes: ["select"], + }, + { + value: "is_empty", + label: "為空", + hasValue: false, + allowedTypes: ["select"], + }, + { + value: "is_not_empty", + label: "不為空", + hasValue: false, + allowedTypes: ["select"], + }, ], - + // 多選類型運算符 multi_select: [ - { value: 'contains', label: '包含', hasValue: true, allowedTypes: ['multi_select'] }, - { value: 'does_not_contain', label: '不包含', hasValue: true, allowedTypes: ['multi_select'] }, - { value: 'is_empty', label: '為空', hasValue: false, allowedTypes: ['multi_select'] }, - { value: 'is_not_empty', label: '不為空', hasValue: false, allowedTypes: ['multi_select'] }, + { + value: "contains", + label: "包含", + hasValue: true, + allowedTypes: ["multi_select"], + }, + { + value: "does_not_contain", + label: "不包含", + hasValue: true, + allowedTypes: ["multi_select"], + }, + { + value: "is_empty", + label: "為空", + hasValue: false, + allowedTypes: ["multi_select"], + }, + { + value: "is_not_empty", + label: "不為空", + hasValue: false, + allowedTypes: ["multi_select"], + }, ], - + // 日期類型運算符 date: [ - { value: 'equals', label: '等於', hasValue: true, allowedTypes: ['date'] }, - { value: 'before', label: '早於', hasValue: true, allowedTypes: ['date'] }, - { value: 'after', label: '晚於', hasValue: true, allowedTypes: ['date'] }, - { value: 'on_or_before', label: '不晚於', hasValue: true, allowedTypes: ['date'] }, - { value: 'on_or_after', label: '不早於', hasValue: true, allowedTypes: ['date'] }, - { value: 'between', label: '介於', hasValue: true, allowedTypes: ['date'] }, - { value: 'past_week', label: '過去一週', hasValue: false, allowedTypes: ['date'] }, - { value: 'past_month', label: '過去一個月', hasValue: false, allowedTypes: ['date'] }, - { value: 'past_year', label: '過去一年', hasValue: false, allowedTypes: ['date'] }, - { value: 'is_empty', label: '為空', hasValue: false, allowedTypes: ['date'] }, - { value: 'is_not_empty', label: '不為空', hasValue: false, allowedTypes: ['date'] }, + { value: "equals", label: "等於", hasValue: true, allowedTypes: ["date"] }, + { value: "before", label: "早於", hasValue: true, allowedTypes: ["date"] }, + { value: "after", label: "晚於", hasValue: true, allowedTypes: ["date"] }, + { + value: "on_or_before", + label: "不晚於", + hasValue: true, + allowedTypes: ["date"], + }, + { + value: "on_or_after", + label: "不早於", + hasValue: true, + allowedTypes: ["date"], + }, + { value: "between", label: "介於", hasValue: true, allowedTypes: ["date"] }, + { + value: "past_week", + label: "過去一週", + hasValue: false, + allowedTypes: ["date"], + }, + { + value: "past_month", + label: "過去一個月", + hasValue: false, + allowedTypes: ["date"], + }, + { + value: "past_year", + label: "過去一年", + hasValue: false, + allowedTypes: ["date"], + }, + { + value: "is_empty", + label: "為空", + hasValue: false, + allowedTypes: ["date"], + }, + { + value: "is_not_empty", + label: "不為空", + hasValue: false, + allowedTypes: ["date"], + }, ], - + // 勾選方塊類型運算符 checkbox: [ - { value: 'equals', label: '等於', hasValue: true, allowedTypes: ['checkbox'] }, - { value: 'does_not_equal', label: '不等於', hasValue: true, allowedTypes: ['checkbox'] }, + { + value: "equals", + label: "等於", + hasValue: true, + allowedTypes: ["checkbox"], + }, + { + value: "does_not_equal", + label: "不等於", + hasValue: true, + allowedTypes: ["checkbox"], + }, ], // 狀態類型運算符 status: [ - { value: 'equals', label: '等於', hasValue: true, allowedTypes: ['status'] }, - { value: 'does_not_equal', label: '不等於', hasValue: true, allowedTypes: ['status'] }, - { value: 'is_empty', label: '為空', hasValue: false, allowedTypes: ['status'] }, - { value: 'is_not_empty', label: '不為空', hasValue: false, allowedTypes: ['status'] }, + { + value: "equals", + label: "等於", + hasValue: true, + allowedTypes: ["status"], + }, + { + value: "does_not_equal", + label: "不等於", + hasValue: true, + allowedTypes: ["status"], + }, + { + value: "is_empty", + label: "為空", + hasValue: false, + allowedTypes: ["status"], + }, + { + value: "is_not_empty", + label: "不為空", + hasValue: false, + allowedTypes: ["status"], + }, ], -} +}; // 篩選條件介面 export interface FilterCondition { - id: string - property: string - operator: string - value?: string | number | boolean - endValue?: string | number // 為 between 運算符添加結束值 - logicalOperator?: 'and' | 'or' + id: string; + property: string; + operator: string; + value?: string | number | boolean | string[]; // 支援陣列值,用於 multi_select 的 contains/does_not_contain + endValue?: string | number; // 為 between 運算符添加結束值 + logicalOperator?: "and" | "or"; } // 篩選組介面(支援嵌套子群組) export interface FilterGroup { - id: string - conditions: FilterCondition[] - subgroups?: FilterGroup[] // 新增子群組支援 - logicalOperator: 'and' | 'or' + id: string; + conditions: FilterCondition[]; + subgroups?: FilterGroup[]; // 新增子群組支援 + logicalOperator: "and" | "or"; } // Query Builder 組件屬性 interface QueryBuilderProps { - properties: DatabaseProperty[] - value?: FilterGroup[] - onChange?: (filters: FilterGroup[]) => void - className?: string + properties: DatabaseProperty[]; + value?: FilterGroup[]; + onChange?: (filters: FilterGroup[]) => void; + className?: string; } export const QueryBuilder: React.FC = ({ properties, value = [], onChange, - className = '', + className = "", }) => { // 創建新的篩選條件 const createNewCondition = useCallback((): FilterCondition => { return { id: `condition_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - property: '', - operator: '', - value: '', - } - }, []) + property: "", + operator: "", + value: "", + }; + }, []); // 創建新的篩選組 const createNewGroup = useCallback((): FilterGroup => { @@ -186,633 +386,797 @@ export const QueryBuilder: React.FC = ({ id: `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, conditions: [createNewCondition()], subgroups: [], // 初始化子群組為空陣列 - logicalOperator: 'and', - } - }, [createNewCondition]) + logicalOperator: "and", + }; + }, [createNewCondition]); - const [groups, setGroups] = useState(value.length > 0 ? value : [createNewGroup()]) + const [groups, setGroups] = useState( + value.length > 0 ? value : [createNewGroup()] + ); // 監聽外部 value 變化並同步內部狀態 React.useEffect(() => { if (value.length === 0) { // 當外部清空篩選條件時,重置為一個空的篩選組 - setGroups([createNewGroup()]) + setGroups([createNewGroup()]); } else { // 當外部有篩選條件時,同步更新 - setGroups(value) + setGroups(value); } - }, [value, createNewGroup]) + }, [value, createNewGroup]); // 根據屬性類型獲取可用的運算符 - const getOperatorsForProperty = useCallback((propertyName: string): OperatorConfig[] => { - const property = properties.find(p => p.name === propertyName); - // 若找不到 property,但 propertyName 字串包含 multi_select,仍回傳 multi_select 運算子 - if (!property) { - if (propertyName && propertyName.toLowerCase().includes('multi_select')) { - return OPERATORS.multi_select; + const getOperatorsForProperty = useCallback( + (propertyName: string): OperatorConfig[] => { + const property = properties.find((p) => p.name === propertyName); + // 若找不到 property,但 propertyName 字串包含 multi_select,仍回傳 multi_select 運算子 + if (!property) { + if ( + propertyName && + propertyName.toLowerCase().includes("multi_select") + ) { + return OPERATORS.multi_select; + } + return OPERATORS.text; } - return OPERATORS.text; - } - switch (property.type) { - case 'number': - case 'formula': - case 'rollup': - return OPERATORS.number; - case 'title': - case 'rich_text': - return OPERATORS.text; - case 'select': - return OPERATORS.select; - case 'multi_select': - return OPERATORS.multi_select; - case 'date': - return OPERATORS.date; - case 'checkbox': - return OPERATORS.checkbox; - case 'status': - return OPERATORS.status; - default: - return OPERATORS.text; - } - }, [properties]) + switch (property.type) { + case "number": + case "formula": + case "rollup": + return OPERATORS.number; + case "title": + case "rich_text": + return OPERATORS.text; + case "select": + return OPERATORS.select; + case "multi_select": + return OPERATORS.multi_select; + case "date": + return OPERATORS.date; + case "checkbox": + return OPERATORS.checkbox; + case "status": + return OPERATORS.status; + default: + return OPERATORS.text; + } + }, + [properties] + ); // 更新組並通知父組件 - const updateGroups = useCallback((newGroups: FilterGroup[]) => { - setGroups(newGroups) - onChange?.(newGroups) - }, [onChange]) + const updateGroups = useCallback( + (newGroups: FilterGroup[]) => { + setGroups(newGroups); + onChange?.(newGroups); + }, + [onChange] + ); // 添加新的篩選組 const addGroup = useCallback(() => { - const newGroups = [...groups, createNewGroup()] - updateGroups(newGroups) - }, [groups, updateGroups, createNewGroup]) + const newGroups = [...groups, createNewGroup()]; + updateGroups(newGroups); + }, [groups, updateGroups, createNewGroup]); // 刪除篩選組 - const removeGroup = useCallback((groupId: string) => { - if (groups.length <= 1) return // 至少保留一個組 - const newGroups = groups.filter(g => g.id !== groupId) - updateGroups(newGroups) - }, [groups, updateGroups]) - + const removeGroup = useCallback( + (groupId: string) => { + if (groups.length <= 1) return; // 至少保留一個組 + const newGroups = groups.filter((g) => g.id !== groupId); + updateGroups(newGroups); + }, + [groups, updateGroups] + ); // 添加條件到組 - const addConditionToGroup = useCallback((groupId: string) => { - const newGroups = groups.map(group => - group.id === groupId - ? { ...group, conditions: [...group.conditions, createNewCondition()] } - : group - ) - updateGroups(newGroups) - }, [groups, updateGroups, createNewCondition]) + const addConditionToGroup = useCallback( + (groupId: string) => { + const newGroups = groups.map((group) => + group.id === groupId + ? { + ...group, + conditions: [...group.conditions, createNewCondition()], + } + : group + ); + updateGroups(newGroups); + }, + [groups, updateGroups, createNewCondition] + ); // 從組中刪除條件 - const removeConditionFromGroup = useCallback((groupId: string, conditionId: string) => { - const newGroups = groups.map(group => { - if (group.id === groupId) { - const newConditions = group.conditions.filter(c => c.id !== conditionId) - // 至少保留一個條件 - return { - ...group, - conditions: newConditions.length > 0 ? newConditions : [createNewCondition()] + const removeConditionFromGroup = useCallback( + (groupId: string, conditionId: string) => { + const newGroups = groups.map((group) => { + if (group.id === groupId) { + const newConditions = group.conditions.filter( + (c) => c.id !== conditionId + ); + // 至少保留一個條件 + return { + ...group, + conditions: + newConditions.length > 0 ? newConditions : [createNewCondition()], + }; } - } - return group - }) - updateGroups(newGroups) - }, [groups, updateGroups, createNewCondition]) + return group; + }); + updateGroups(newGroups); + }, + [groups, updateGroups, createNewCondition] + ); // 更新條件 - const updateCondition = useCallback(( - groupId: string, - conditionId: string, - updates: Partial - ) => { - const updateGroupRecursively = (group: FilterGroup): FilterGroup => { - if (group.id === groupId) { - return { - ...group, - conditions: group.conditions.map(condition => - condition.id === conditionId - ? { ...condition, ...updates } - : condition - ) + const updateCondition = useCallback( + ( + groupId: string, + conditionId: string, + updates: Partial + ) => { + const updateGroupRecursively = (group: FilterGroup): FilterGroup => { + if (group.id === groupId) { + return { + ...group, + conditions: group.conditions.map((condition) => + condition.id === conditionId + ? { ...condition, ...updates } + : condition + ), + }; } - } - - // 遞歸檢查子群組 - if (group.subgroups && group.subgroups.length > 0) { - const updatedSubgroups = group.subgroups.map(updateGroupRecursively) - if (updatedSubgroups.some((sg, index) => sg !== group.subgroups![index])) { - return { ...group, subgroups: updatedSubgroups } + + // 遞歸檢查子群組 + if (group.subgroups && group.subgroups.length > 0) { + const updatedSubgroups = group.subgroups.map(updateGroupRecursively); + if ( + updatedSubgroups.some((sg, index) => sg !== group.subgroups![index]) + ) { + return { ...group, subgroups: updatedSubgroups }; + } } - } - - return group - } - const newGroups = groups.map(updateGroupRecursively) - updateGroups(newGroups) - }, [groups, updateGroups]) + return group; + }; + + const newGroups = groups.map(updateGroupRecursively); + updateGroups(newGroups); + }, + [groups, updateGroups] + ); // 添加子群組到指定群組 - const addSubgroupToGroup = useCallback((parentGroupId: string) => { - const addSubgroupRecursively = (group: FilterGroup): FilterGroup => { - if (group.id === parentGroupId) { - return { - ...group, - subgroups: [...(group.subgroups || []), createNewGroup()] + const addSubgroupToGroup = useCallback( + (parentGroupId: string) => { + const addSubgroupRecursively = (group: FilterGroup): FilterGroup => { + if (group.id === parentGroupId) { + return { + ...group, + subgroups: [...(group.subgroups || []), createNewGroup()], + }; } - } - - // 遞歸檢查子群組 - if (group.subgroups && group.subgroups.length > 0) { - const updatedSubgroups = group.subgroups.map(addSubgroupRecursively) - if (updatedSubgroups.some((sg, index) => sg !== group.subgroups![index])) { - return { ...group, subgroups: updatedSubgroups } + + // 遞歸檢查子群組 + if (group.subgroups && group.subgroups.length > 0) { + const updatedSubgroups = group.subgroups.map(addSubgroupRecursively); + if ( + updatedSubgroups.some((sg, index) => sg !== group.subgroups![index]) + ) { + return { ...group, subgroups: updatedSubgroups }; + } } - } - - return group - } - const newGroups = groups.map(addSubgroupRecursively) - updateGroups(newGroups) - }, [groups, updateGroups, createNewGroup]) + return group; + }; + + const newGroups = groups.map(addSubgroupRecursively); + updateGroups(newGroups); + }, + [groups, updateGroups, createNewGroup] + ); // 從群組中刪除子群組 - const removeSubgroupFromGroup = useCallback((parentGroupId: string, subgroupId: string) => { - const removeSubgroupRecursively = (group: FilterGroup): FilterGroup => { - if (group.id === parentGroupId) { - return { - ...group, - subgroups: group.subgroups?.filter(sg => sg.id !== subgroupId) || [] + const removeSubgroupFromGroup = useCallback( + (parentGroupId: string, subgroupId: string) => { + const removeSubgroupRecursively = (group: FilterGroup): FilterGroup => { + if (group.id === parentGroupId) { + return { + ...group, + subgroups: + group.subgroups?.filter((sg) => sg.id !== subgroupId) || [], + }; } - } - - // 遞歸檢查子群組 - if (group.subgroups && group.subgroups.length > 0) { - const updatedSubgroups = group.subgroups.map(removeSubgroupRecursively) - if (updatedSubgroups.some((sg, index) => sg !== group.subgroups![index])) { - return { ...group, subgroups: updatedSubgroups } + + // 遞歸檢查子群組 + if (group.subgroups && group.subgroups.length > 0) { + const updatedSubgroups = group.subgroups.map( + removeSubgroupRecursively + ); + if ( + updatedSubgroups.some((sg, index) => sg !== group.subgroups![index]) + ) { + return { ...group, subgroups: updatedSubgroups }; + } } - } - - return group - } - const newGroups = groups.map(removeSubgroupRecursively) - updateGroups(newGroups) - }, [groups, updateGroups]) + return group; + }; + + const newGroups = groups.map(removeSubgroupRecursively); + updateGroups(newGroups); + }, + [groups, updateGroups] + ); // 遞歸更新子群組的邏輯運算符 - const updateSubgroupLogicalOperator = useCallback((subgroupId: string, operator: 'and' | 'or') => { - const updateSubgroupRecursively = (group: FilterGroup): FilterGroup => { - if (group.id === subgroupId) { - return { ...group, logicalOperator: operator } - } - - // 遞歸檢查子群組 - if (group.subgroups && group.subgroups.length > 0) { - const updatedSubgroups = group.subgroups.map(updateSubgroupRecursively) - if (updatedSubgroups.some((sg, index) => sg !== group.subgroups![index])) { - return { ...group, subgroups: updatedSubgroups } + const updateSubgroupLogicalOperator = useCallback( + (subgroupId: string, operator: "and" | "or") => { + const updateSubgroupRecursively = (group: FilterGroup): FilterGroup => { + if (group.id === subgroupId) { + return { ...group, logicalOperator: operator }; + } + + // 遞歸檢查子群組 + if (group.subgroups && group.subgroups.length > 0) { + const updatedSubgroups = group.subgroups.map( + updateSubgroupRecursively + ); + if ( + updatedSubgroups.some((sg, index) => sg !== group.subgroups![index]) + ) { + return { ...group, subgroups: updatedSubgroups }; + } } - } - - return group - } - const newGroups = groups.map(updateSubgroupRecursively) - updateGroups(newGroups) - }, [groups, updateGroups]) + return group; + }; + + const newGroups = groups.map(updateSubgroupRecursively); + updateGroups(newGroups); + }, + [groups, updateGroups] + ); // 遞歸添加條件到子群組 - const addConditionToSubgroup = useCallback((subgroupId: string) => { - const addConditionRecursively = (group: FilterGroup): FilterGroup => { - if (group.id === subgroupId) { - return { - ...group, - conditions: [...group.conditions, createNewCondition()] + const addConditionToSubgroup = useCallback( + (subgroupId: string) => { + const addConditionRecursively = (group: FilterGroup): FilterGroup => { + if (group.id === subgroupId) { + return { + ...group, + conditions: [...group.conditions, createNewCondition()], + }; } - } - - // 遞歸檢查子群組 - if (group.subgroups && group.subgroups.length > 0) { - const updatedSubgroups = group.subgroups.map(addConditionRecursively) - if (updatedSubgroups.some((sg, index) => sg !== group.subgroups![index])) { - return { ...group, subgroups: updatedSubgroups } + + // 遞歸檢查子群組 + if (group.subgroups && group.subgroups.length > 0) { + const updatedSubgroups = group.subgroups.map(addConditionRecursively); + if ( + updatedSubgroups.some((sg, index) => sg !== group.subgroups![index]) + ) { + return { ...group, subgroups: updatedSubgroups }; + } } - } - - return group - } - const newGroups = groups.map(addConditionRecursively) - updateGroups(newGroups) - }, [groups, updateGroups, createNewCondition]) + return group; + }; + + const newGroups = groups.map(addConditionRecursively); + updateGroups(newGroups); + }, + [groups, updateGroups, createNewCondition] + ); // 遞歸從子群組中刪除條件 - const removeConditionFromSubgroup = useCallback((subgroupId: string, conditionId: string) => { - const removeConditionRecursively = (group: FilterGroup): FilterGroup => { - if (group.id === subgroupId) { - const newConditions = group.conditions.filter(c => c.id !== conditionId) - return { - ...group, - conditions: newConditions.length > 0 ? newConditions : [createNewCondition()] + const removeConditionFromSubgroup = useCallback( + (subgroupId: string, conditionId: string) => { + const removeConditionRecursively = (group: FilterGroup): FilterGroup => { + if (group.id === subgroupId) { + const newConditions = group.conditions.filter( + (c) => c.id !== conditionId + ); + return { + ...group, + conditions: + newConditions.length > 0 ? newConditions : [createNewCondition()], + }; } - } - - // 遞歸檢查子群組 - if (group.subgroups && group.subgroups.length > 0) { - const updatedSubgroups = group.subgroups.map(removeConditionRecursively) - if (updatedSubgroups.some((sg, index) => sg !== group.subgroups![index])) { - return { ...group, subgroups: updatedSubgroups } + + // 遞歸檢查子群組 + if (group.subgroups && group.subgroups.length > 0) { + const updatedSubgroups = group.subgroups.map( + removeConditionRecursively + ); + if ( + updatedSubgroups.some((sg, index) => sg !== group.subgroups![index]) + ) { + return { ...group, subgroups: updatedSubgroups }; + } } - } - - return group - } - const newGroups = groups.map(removeConditionRecursively) - updateGroups(newGroups) - }, [groups, updateGroups, createNewCondition]) + return group; + }; + + const newGroups = groups.map(removeConditionRecursively); + updateGroups(newGroups); + }, + [groups, updateGroups, createNewCondition] + ); // 渲染值輸入框 - const renderValueInput = useCallback((condition: FilterCondition, groupId: string) => { - const property = properties.find(p => p.name === condition.property); - // select/multi_select: 僅在 contains/does_not_contain 時顯示多選下拉 - if (property && (property.type === 'multi_select' || property.type === 'select')) { - const multiSelectOps = ['contains', 'does_not_contain']; - let op = condition.operator; - if (!op) { - // 自動補 operator - op = 'contains'; - setTimeout(() => updateCondition(groupId, condition.id, { operator: 'contains' }), 0); - } - // select/multi_select: 包含/不包含時顯示多選,其餘顯示單選 - if (multiSelectOps.includes(op)) { - // 確保 value 為陣列且型別正確 - let selectedValues: string[] = []; - if (Array.isArray(condition.value)) { - selectedValues = condition.value.filter((v): v is string => typeof v === 'string'); - } else if (typeof condition.value === 'string') { - selectedValues = [condition.value]; - } else if (condition.value == null) { - selectedValues = []; + const renderValueInput = useCallback( + (condition: FilterCondition, groupId: string) => { + const property = properties.find((p) => p.name === condition.property); + // select/multi_select: 僅在 contains/does_not_contain 時顯示多選下拉 + if ( + property && + (property.type === "multi_select" || property.type === "select") + ) { + const multiSelectOps = ["contains", "does_not_contain"]; + let op = condition.operator; + if (!op) { + // 自動補 operator + op = "contains"; + setTimeout( + () => + updateCondition(groupId, condition.id, { operator: "contains" }), + 0 + ); } - // options 型別安全 - const options = Array.isArray((property as any)?.options) ? (property as any).options : []; - return ( -
- updateCondition(groupId, condition.id, { value: vals as any })} - tagContainerClassName="flex flex-nowrap overflow-hidden whitespace-nowrap gap-1 max-w-full" - /> -
- ); - } else if (property.type === 'select') { - // 其餘 select 顯示單選 - return ( - - ); - } - } - // 其餘型別 - const operator = getOperatorsForProperty(condition.property).find(op => op.value === condition.operator); - if (!operator?.hasValue) return null; - - switch (property?.type) { - case 'number': - case 'formula': - case 'rollup': - return ( - updateCondition(groupId, condition.id, { value: parseFloat(e.target.value) || 0 })} - className="w-32" - /> - ); - case 'checkbox': - return ( - - ); - case 'select': - case 'status': - return ( - - ); - case 'date': - if (condition.operator === 'between') { - // 檢查結束日期不可早於開始日期,於任一日期變動時即時顯示錯誤 - const start = condition.value ? new Date(condition.value as string) : null; - const end = condition.endValue ? new Date(condition.endValue as string) : null; - const invalid = start && end && end < start; + // select/multi_select: 包含/不包含時顯示多選,其餘顯示單選 + if (multiSelectOps.includes(op)) { + // 確保 value 為陣列且型別正確 + let selectedValues: string[] = []; + if (Array.isArray(condition.value)) { + selectedValues = condition.value.filter( + (v): v is string => typeof v === "string" + ); + } else if (typeof condition.value === "string") { + selectedValues = [condition.value]; + } else if (condition.value == null) { + selectedValues = []; + } + // options 型別安全 + const options = Array.isArray((property as any)?.options) + ? (property as any).options + : []; return ( -
- { - updateCondition(groupId, condition.id, { value }); - }} - placeholder="開始日期" - className="w-36" - /> - - { - updateCondition(groupId, condition.id, { endValue: value }); - }} - placeholder="結束日期" - className="w-36" +
+ + updateCondition(groupId, condition.id, { value: vals as any }) + } + tagContainerClassName="flex flex-nowrap overflow-hidden whitespace-nowrap gap-1 max-w-full" /> - {invalid && ( - 結束日期不能早於開始日期 - )}
); + } else if (property.type === "select") { + // 其餘 select 顯示單選 + return ( + + ); } - return ( - updateCondition(groupId, condition.id, { value })} - placeholder="選擇日期" - className="w-40" - /> - ); - default: - return ( - updateCondition(groupId, condition.id, { value: e.target.value })} - className="w-48" - /> - ); - } - }, [properties, getOperatorsForProperty, updateCondition]); + } + // 其餘型別 + const operator = getOperatorsForProperty(condition.property).find( + (op) => op.value === condition.operator + ); + if (!operator?.hasValue) return null; - // 遞歸渲染群組(支援子群組) - const renderGroup = useCallback((group: FilterGroup, groupIndex: number, isSubgroup: boolean = false, parentGroupId?: string) => { - return ( -
- {/* 組標題和控制項 */} -
-
+ switch (property?.type) { + case "number": + case "formula": + case "rollup": + return ( + + updateCondition(groupId, condition.id, { + value: parseFloat(e.target.value) || 0, + }) + } + className="w-32" + /> + ); + case "checkbox": + return ( - - {isSubgroup ? `子群組 ${groupIndex + 1}` : `群組 ${groupIndex + 1}`} - -
- -
-
+ ); + } + return ( + + updateCondition(groupId, condition.id, { value }) + } + placeholder="選擇日期" + className="w-40" + /> + ); + default: + return ( + + updateCondition(groupId, condition.id, { + value: e.target.value, + }) + } + className="w-48" + /> + ); + } + }, + [properties, getOperatorsForProperty, updateCondition] + ); - {/* 屬性選擇 */} + // 遞歸渲染群組(支援子群組) + const renderGroup = useCallback( + ( + group: FilterGroup, + groupIndex: number, + isSubgroup: boolean = false, + parentGroupId?: string + ) => { + return ( +
+ {/* 組標題和控制項 */} +
+
+ + {isSubgroup + ? `子群組 ${groupIndex + 1}` + : `群組 ${groupIndex + 1}`} + +
- {/* 運算符選擇 */} - - - {/* 值輸入 */} - {renderValueInput(condition, group.id)} - - {/* 刪除條件按鈕 */} - {group.conditions.length > 1 && ( + + 條件 + + + {((!isSubgroup && groups.length > 1) || + (isSubgroup && parentGroupId)) && ( )}
- ))} -
+
+ + {/* 條件列表 */} +
+ {group.conditions.map((condition, conditionIndex) => ( +
+ {/* 邏輯運算符(第一個條件不顯示) */} + {conditionIndex > 0 && ( +
+ + {group.logicalOperator.toUpperCase()} + +
+ )} - {/* 子群組 */} - {group.subgroups && group.subgroups.length > 0 && ( -
-
子群組
- {group.subgroups.map((subgroup, subgroupIndex) => ( - renderGroup(subgroup, subgroupIndex, true, group.id) + {/* 屬性選擇 */} + + + {/* 運算符選擇 */} + + + {/* 值輸入 */} + {renderValueInput(condition, group.id)} + + {/* 刪除條件按鈕 */} + {group.conditions.length > 1 && ( + + )} +
))}
- )} -
- ) - }, [ - groups, - properties, - updateGroups, - updateSubgroupLogicalOperator, - addConditionToGroup, - addConditionToSubgroup, - addSubgroupToGroup, - removeGroup, - removeSubgroupFromGroup, - removeConditionFromGroup, - removeConditionFromSubgroup, - updateCondition, - getOperatorsForProperty, - renderValueInput - ]) + + {/* 子群組 */} + {group.subgroups && group.subgroups.length > 0 && ( +
+
子群組
+ {group.subgroups.map((subgroup, subgroupIndex) => + renderGroup(subgroup, subgroupIndex, true, group.id) + )} +
+ )} +
+ ); + }, + [ + groups, + properties, + updateGroups, + updateSubgroupLogicalOperator, + addConditionToGroup, + addConditionToSubgroup, + addSubgroupToGroup, + removeGroup, + removeSubgroupFromGroup, + removeConditionFromGroup, + removeConditionFromSubgroup, + updateCondition, + getOperatorsForProperty, + renderValueInput, + ] + ); return (
@@ -833,29 +1197,41 @@ export const QueryBuilder: React.FC = ({ {groups.map((group, groupIndex) => renderGroup(group, groupIndex))}
- ) -} + ); +}; // 工具函數:將QueryBuilder的輸出轉換為Notion API的filter格式 -export const convertToNotionFilter = (groups: FilterGroup[], properties: DatabaseProperty[] = []): any => { +export const convertToNotionFilter = ( + groups: FilterGroup[], + properties: DatabaseProperty[] = [] +): any => { if (groups.length === 0) return undefined; // 屬性型別對應 Notion API const getPropertyTypeForFilter = (propertyName: string): string => { - const property = properties.find(p => p.name === propertyName); - if (!property) return 'rich_text'; + const property = properties.find((p) => p.name === propertyName); + if (!property) return "rich_text"; switch (property.type) { - case 'title': return 'title'; - case 'rich_text': return 'rich_text'; - case 'number': - case 'formula': - case 'rollup': return 'number'; - case 'select': return 'select'; - case 'multi_select': return 'multi_select'; - case 'status': return 'status'; - case 'date': return 'date'; - case 'checkbox': return 'checkbox'; - default: return 'rich_text'; + case "title": + return "title"; + case "rich_text": + return "rich_text"; + case "number": + case "formula": + case "rollup": + return "number"; + case "select": + return "select"; + case "multi_select": + return "multi_select"; + case "status": + return "status"; + case "date": + return "date"; + case "checkbox": + return "checkbox"; + default: + return "rich_text"; } }; @@ -863,73 +1239,173 @@ export const convertToNotionFilter = (groups: FilterGroup[], properties: Databas const convertCondition = (condition: FilterCondition): any => { const { property, operator, value } = condition; if (!property || !operator) return null; - // select 欄位複選: 轉換為 or/and equals/does_not_equal 子條件 + + console.log("convertCondition called with:", { + property, + operator, + value, + type: typeof value, + isArray: Array.isArray(value), + }); + + // select/multi_select 欄位複選: 轉換為 or/and equals/does_not_equal 子條件 const propertyType = getPropertyTypeForFilter(property); - if (propertyType === 'select' && (operator === 'contains' || operator === 'does_not_contain') && Array.isArray(value)) { - // contains: or [equals] - // does_not_contain: and [does_not_equal] - const subOperator = operator === 'contains' ? 'equals' : 'does_not_equal'; - const logic = operator === 'contains' ? 'or' : 'and'; + console.log("propertyType:", propertyType); + + if ( + (propertyType === "select" || propertyType === "multi_select") && + (operator === "contains" || operator === "does_not_contain") && + Array.isArray(value) + ) { + console.log("Entering array conversion for select/multi_select"); + // 對於 select 和 multi_select,使用不同的子操作符 + let subOperator: string; + if (propertyType === "select") { + // select 類型使用 equals/does_not_equal + subOperator = operator === "contains" ? "equals" : "does_not_equal"; + } else { + // multi_select 類型使用 contains/does_not_contain,但每個值單獨處理 + subOperator = operator === "contains" ? "contains" : "does_not_contain"; + } + + const logic = operator === "contains" ? "or" : "and"; const subFilters = value.map((v) => ({ property, - [propertyType]: { [subOperator]: v } + [propertyType]: { [subOperator]: v }, })); + console.log("Array conversion result:", { [logic]: subFilters }); return { [logic]: subFilters }; } + + // 特殊處理:對於 multi_select 的 contains/does_not_contain,如果值不是陣列,也要特殊處理 + if ( + propertyType === "multi_select" && + (operator === "contains" || operator === "does_not_contain") + ) { + console.log("Entering fallback conversion for multi_select"); + // 如果值不是陣列,轉換為陣列處理 + const valueArray = Array.isArray(value) ? value : value ? [value] : []; + if (valueArray.length > 0) { + // multi_select 使用 contains/does_not_contain + const subOperator = + operator === "contains" ? "contains" : "does_not_contain"; + const logic = operator === "contains" ? "or" : "and"; + const subFilters = valueArray.map((v) => ({ + property, + [propertyType]: { [subOperator]: v }, + })); + console.log("Fallback conversion result:", { [logic]: subFilters }); + return { [logic]: subFilters }; + } + } // 其餘型別 const filterValue: any = {}; switch (operator) { - case 'equals': filterValue.equals = value; break; - case 'does_not_equal': filterValue.does_not_equal = value; break; - case 'contains': filterValue.contains = value; break; - case 'does_not_contain': filterValue.does_not_contain = value; break; - case 'starts_with': filterValue.starts_with = value; break; - case 'ends_with': filterValue.ends_with = value; break; - case 'greater_than': filterValue.greater_than = value; break; - case 'less_than': filterValue.less_than = value; break; - case 'greater_than_or_equal_to': filterValue.greater_than_or_equal_to = value; break; - case 'less_than_or_equal_to': filterValue.less_than_or_equal_to = value; break; - case 'is_empty': filterValue.is_empty = true; break; - case 'is_not_empty': filterValue.is_not_empty = true; break; - case 'before': filterValue.before = value; break; - case 'after': filterValue.after = value; break; - case 'on_or_before': filterValue.on_or_before = value; break; - case 'on_or_after': filterValue.on_or_after = value; break; - case 'past_week': filterValue.past_week = {}; break; - case 'past_month': filterValue.past_month = {}; break; - case 'past_year': filterValue.past_year = {}; break; - case 'between': + case "equals": + filterValue.equals = value; + break; + case "does_not_equal": + filterValue.does_not_equal = value; + break; + case "contains": + // 對於 multi_select,contains 應該已經在上面處理了,這裡只處理其他類型 + if (propertyType !== "multi_select") { + filterValue.contains = value; + } + break; + case "does_not_contain": + // 對於 multi_select,does_not_contain 應該已經在上面處理了,這裡只處理其他類型 + if (propertyType !== "multi_select") { + filterValue.does_not_contain = value; + } + break; + case "starts_with": + filterValue.starts_with = value; + break; + case "ends_with": + filterValue.ends_with = value; + break; + case "greater_than": + filterValue.greater_than = value; + break; + case "less_than": + filterValue.less_than = value; + break; + case "greater_than_or_equal_to": + filterValue.greater_than_or_equal_to = value; + break; + case "less_than_or_equal_to": + filterValue.less_than_or_equal_to = value; + break; + case "is_empty": + filterValue.is_empty = true; + break; + case "is_not_empty": + filterValue.is_not_empty = true; + break; + case "before": + filterValue.before = value; + break; + case "after": + filterValue.after = value; + break; + case "on_or_before": + filterValue.on_or_before = value; + break; + case "on_or_after": + filterValue.on_or_after = value; + break; + case "past_week": + filterValue.past_week = {}; + break; + case "past_month": + filterValue.past_month = {}; + break; + case "past_year": + filterValue.past_year = {}; + break; + case "between": if (condition.value && condition.endValue) { return { and: [ { property, - [getPropertyTypeForFilter(property)]: { on_or_after: condition.value } + [getPropertyTypeForFilter(property)]: { + on_or_after: condition.value, + }, }, { property, - [getPropertyTypeForFilter(property)]: { on_or_before: condition.endValue } - } - ] + [getPropertyTypeForFilter(property)]: { + on_or_before: condition.endValue, + }, + }, + ], }; } return null; } + + // 檢查 filterValue 是否為空物件,如果是則返回 null + if (Object.keys(filterValue).length === 0) { + return null; + } + return { property, - [getPropertyTypeForFilter(property)]: filterValue + [getPropertyTypeForFilter(property)]: filterValue, }; }; // 遞迴展平同類型 compound(僅展平同名 and/or,不跨層) function flattenSameCompound(obj: any): any { - if (typeof obj !== 'object' || obj === null) return obj; + if (typeof obj !== "object" || obj === null) return obj; if (Array.isArray(obj)) return obj.map(flattenSameCompound); if (obj.and || obj.or) { - const key = obj.and ? 'and' : 'or'; + const key = obj.and ? "and" : "or"; let arr = (obj[key] as any[]).map(flattenSameCompound); // 展平同名 compound - arr = arr.flatMap(item => (item && item[key]) ? item[key] : [item]); + arr = arr.flatMap((item) => (item && item[key] ? item[key] : [item])); return { [key]: arr }; } return obj; @@ -937,8 +1413,12 @@ export const convertToNotionFilter = (groups: FilterGroup[], properties: Databas // 單一組轉換(遞迴處理子群組) const convertGroup = (group: FilterGroup): any => { - const validConditions = group.conditions.map(convertCondition).filter(Boolean); - const validSubgroups = group.subgroups ? group.subgroups.map(convertGroup).filter(Boolean) : []; + const validConditions = group.conditions + .map(convertCondition) + .filter(Boolean); + const validSubgroups = group.subgroups + ? group.subgroups.map(convertGroup).filter(Boolean) + : []; const allItems = [...validConditions, ...validSubgroups]; if (allItems.length === 0) return null; if (allItems.length === 1) return allItems[0]; @@ -952,4 +1432,4 @@ export const convertToNotionFilter = (groups: FilterGroup[], properties: Databas // 多組時,頂層只包一層 and,且展平同名 compound return flattenSameCompound({ and: validGroups }); -} +}; diff --git a/frontend/src/components/settings-panel.tsx b/frontend/src/components/settings-panel.tsx index f953ecf..d7364ff 100644 --- a/frontend/src/components/settings-panel.tsx +++ b/frontend/src/components/settings-panel.tsx @@ -1,14 +1,31 @@ -'use client' - -import React, { useState } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { useNotionStore } from '@/lib/store' -import { notionApi, dataProcessor, snapshotApi } from '@/lib/api' -import { BarChart3, TrendingUp, PieChart, Loader2, Target, Link, BarChart, Lightbulb, Filter, CheckCircle } from 'lucide-react' -import { NotionLogo } from '@/components/ui/notion-logo' -import { QueryBuilderModal } from '@/components/query-builder-modal' +"use client"; + +import React, { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useNotionStore } from "@/lib/store"; +import { notionApi, dataProcessor, snapshotApi } from "@/lib/api"; +import { + BarChart3, + TrendingUp, + PieChart, + Loader2, + Target, + Link, + BarChart, + Lightbulb, + Filter, + CheckCircle, +} from "lucide-react"; +import { NotionLogo } from "@/components/ui/notion-logo"; +import { QueryBuilderModal } from "@/components/query-builder-modal"; export const SettingsPanel: React.FC = () => { const { @@ -48,104 +65,119 @@ export const SettingsPanel: React.FC = () => { setSnapshotMode, currentSnapshotId, setCurrentSnapshotId, - } = useNotionStore() + } = useNotionStore(); - const [isLoadingDatabases, setIsLoadingDatabases] = useState(false) - const [isQueryBuilderOpen, setIsQueryBuilderOpen] = useState(false) + const [isLoadingDatabases, setIsLoadingDatabases] = useState(false); + const [isQueryBuilderOpen, setIsQueryBuilderOpen] = useState(false); const handleLoadDatabases = async () => { if (!token.trim()) { - setError('請輸入有效的 Notion Token') - return + setError("請輸入有效的 Notion Token"); + return; } - setIsLoadingDatabases(true) - setError(null) + setIsLoadingDatabases(true); + setError(null); try { - const databaseList = await notionApi.getDatabases(token) - setDatabases(databaseList) + const databaseList = await notionApi.getDatabases(token); + setDatabases(databaseList); if (databaseList.length === 0) { - setError('未找到可訪問的資料庫,請檢查 Token 權限') + setError("未找到可訪問的資料庫,請檢查 Token 權限"); } } catch (err: any) { - setError(err.response?.data?.error || '載入資料庫失敗,請檢查 Token 是否有效') + setError( + err.response?.data?.error || "載入資料庫失敗,請檢查 Token 是否有效" + ); } finally { - setIsLoadingDatabases(false) + setIsLoadingDatabases(false); } - } + }; const handleDatabaseSelect = async (databaseId: string) => { - - setSelectedDatabase(databaseId) - setFilterGroups([]) // 切換資料庫時清空進階篩選條件 - setError(null) + setSelectedDatabase(databaseId); + setFilterGroups([]); // 切換資料庫時清空進階篩選條件 + setError(null); try { - const dbInfo = await notionApi.getDatabaseProperties(token, databaseId) - setDatabaseProperties(dbInfo.properties) + const dbInfo = await notionApi.getDatabaseProperties(token, databaseId); + setDatabaseProperties(dbInfo.properties); } catch (err: any) { - setError(err.response?.data?.error || '載入資料庫屬性失敗') + setError(err.response?.data?.error || "載入資料庫屬性失敗"); } - } + }; const handleGenerateChart = async () => { - console.log('開始生成圖表...') - console.log('設定:', { - selectedDatabase, - xAxisProperty, - yAxisProperty, - labelProperty, - aggregateFunction, + console.log("開始生成圖表..."); + console.log("設定:", { + selectedDatabase, + xAxisProperty, + yAxisProperty, + labelProperty, + aggregateFunction, snapshotMode, - filterGroups: filterGroups.length > 0 ? filterGroups : '無篩選條件' - }) - + filterGroups: filterGroups.length > 0 ? filterGroups : "無篩選條件", + }); + if (!selectedDatabase || !xAxisProperty) { - setError('請選擇資料庫和 X 軸屬性') - return + setError("請選擇資料庫和 X 軸屬性"); + return; } // 檢查是否需要使用 COUNT 模式 - const hasNumericProperties = getCompatibleProperties(true).length > 0 - const isCountMode = !yAxisProperty || !hasNumericProperties - const actualYAxisProperty = isCountMode ? '__count__' : yAxisProperty - const actualAggregateFunction = isCountMode ? 'COUNT' : aggregateFunction + const hasNumericProperties = getCompatibleProperties(true).length > 0; + const isCountMode = !yAxisProperty || !hasNumericProperties; + const actualYAxisProperty = isCountMode ? "__count__" : yAxisProperty; + const actualAggregateFunction = isCountMode ? "COUNT" : aggregateFunction; // 如果是 COUNT 模式,確保聚合函數設為 COUNT if (isCountMode) { - setAggregateFunction('COUNT') + setAggregateFunction("COUNT"); } - setIsLoading(true) - setError(null) - + setIsLoading(true); + setError(null); + // 重設表格分頁狀態 - setTableData([]) - setHasMoreData(false) - setNextCursor(null) + setTableData([]); + setHasMoreData(false); + setNextCursor(null); try { // 只使用動態模式 - await generateDynamicChart(actualYAxisProperty, actualAggregateFunction) + await generateDynamicChart(actualYAxisProperty, actualAggregateFunction); } catch (err: any) { - console.error('生成圖表錯誤:', err) - console.error('錯誤詳情:', err.response?.data) - setError(err.response?.data?.message || err.message || '生成圖表失敗') + console.error("生成圖表錯誤:", err); + console.error("錯誤詳情:", err.response?.data); + setError(err.response?.data?.message || err.message || "生成圖表失敗"); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; + + const generateDynamicChart = async ( + actualYAxisProperty: string, + actualAggregateFunction: string + ) => { + console.log("正在建立動態快照..."); - const generateDynamicChart = async (actualYAxisProperty: string, actualAggregateFunction: string) => { - console.log('正在建立動態快照...') - try { // 轉換篩選條件為Notion API格式 - const notionFilters = filterGroups.length > 0 ? - await import('@/components/query-builder').then(module => - module.convertToNotionFilter(filterGroups, databaseProperties) - ) : undefined + const notionFilters = + filterGroups.length > 0 + ? await import("@/components/query-builder").then((module) => + module.convertToNotionFilter(filterGroups, databaseProperties) + ) + : undefined; + + console.log( + "Original filterGroups:", + JSON.stringify(filterGroups, null, 2) + ); + console.log( + "Converted notionFilters:", + JSON.stringify(notionFilters, null, 2) + ); // 建立動態快照 const snapshotResponse = await snapshotApi.saveQuerySnapshot({ @@ -159,63 +191,64 @@ export const SettingsPanel: React.FC = () => { snapshotMode, isDemo: false, filters: notionFilters, - }) + }); + + console.log("動態快照建立成功:", snapshotResponse); - console.log('動態快照建立成功:', snapshotResponse) - // 執行動態查詢以取得資料 - const chartData = await snapshotApi.executeQuerySnapshot(snapshotResponse.id) - console.log('動態查詢執行成功:', chartData) - + const chartData = await snapshotApi.executeQuerySnapshot( + snapshotResponse.id + ); + console.log("動態查詢執行成功:", chartData); + // 更新狀態 - setChartData(chartData.data) - setCurrentSnapshotId(snapshotResponse.id) - + setChartData(chartData.data); + setCurrentSnapshotId(snapshotResponse.id); + // 設置原始資料庫資料供資料表格使用 if (chartData.rawData && Array.isArray(chartData.rawData)) { - console.log('設置原始資料庫資料:', chartData.rawData.length, '筆') - setRawDatabaseData(chartData.rawData) + console.log("設置原始資料庫資料:", chartData.rawData.length, "筆"); + setRawDatabaseData(chartData.rawData); } else { - console.warn('動態快照沒有包含原始資料') - setRawDatabaseData([]) + console.warn("動態快照沒有包含原始資料"); + setRawDatabaseData([]); } - + // 更新 URL 以反映當前的動態快照狀態 - const newUrl = new URL(window.location.href) - newUrl.searchParams.set('query', snapshotResponse.id) - window.history.pushState({}, '', newUrl.toString()) - - console.log('圖表生成成功(動態模式)!') - + const newUrl = new URL(window.location.href); + newUrl.searchParams.set("query", snapshotResponse.id); + window.history.pushState({}, "", newUrl.toString()); + + console.log("圖表生成成功(動態模式)!"); } catch (error) { - console.error('動態快照建立失敗:', error) - throw error // 直接拋出錯誤,不再回退到靜態模式 + console.error("動態快照建立失敗:", error); + throw error; // 直接拋出錯誤,不再回退到靜態模式 } - } + }; const chartTypeOptions = [ - { value: 'bar', label: '長條圖', icon: 'BarChart3' }, - { value: 'line', label: '線圖', icon: 'TrendingUp' }, - { value: 'pie', label: '圓餅圖', icon: 'PieChart' }, - { value: 'radar', label: '雷達圖', icon: 'Target' }, - ] + { value: "bar", label: "長條圖", icon: "BarChart3" }, + { value: "line", label: "線圖", icon: "TrendingUp" }, + { value: "pie", label: "圓餅圖", icon: "PieChart" }, + { value: "radar", label: "雷達圖", icon: "Target" }, + ]; const aggregateFunctionOptions = [ - { value: 'SUM', label: 'SUM (加總)' }, - { value: 'AVG', label: 'AVG (平均值)' }, - { value: 'MIN', label: 'MIN (最小值)' }, - { value: 'MAX', label: 'MAX (最大值)' }, - { value: 'COUNT', label: 'COUNT (計數)' }, - ] + { value: "SUM", label: "SUM (加總)" }, + { value: "AVG", label: "AVG (平均值)" }, + { value: "MIN", label: "MIN (最小值)" }, + { value: "MAX", label: "MAX (最大值)" }, + { value: "COUNT", label: "COUNT (計數)" }, + ]; const getCompatibleProperties = (forYAxis = false) => { if (forYAxis) { - return databaseProperties.filter(prop => - ['number', 'formula', 'rollup'].includes(prop.type) - ) + return databaseProperties.filter((prop) => + ["number", "formula", "rollup"].includes(prop.type) + ); } - return databaseProperties - } + return databaseProperties; + }; return (
@@ -239,7 +272,7 @@ export const SettingsPanel: React.FC = () => { className="bg-white border-gray-300 text-gray-900" />
- + @@ -260,7 +293,10 @@ export const SettingsPanel: React.FC = () => { - @@ -294,34 +330,36 @@ export const SettingsPanel: React.FC = () => { {chartTypeOptions.map((option) => { const renderIcon = () => { switch (option.icon) { - case 'BarChart3': - return - case 'TrendingUp': - return - case 'PieChart': - return - case 'Target': - return + case "BarChart3": + return ; + case "TrendingUp": + return ; + case "PieChart": + return ; + case "Target": + return ; default: - return null + return null; } - } - + }; + return ( - ) + ); })} @@ -336,11 +374,17 @@ export const SettingsPanel: React.FC = () => { - {getCompatibleProperties().filter(prop => prop.name && prop.name.trim()).map((prop) => ( - - {prop.name} ({prop.type}) - - ))} + {getCompatibleProperties() + .filter((prop) => prop.name && prop.name.trim()) + .map((prop) => ( + + {prop.name} ({prop.type}) + + ))} @@ -355,18 +399,25 @@ export const SettingsPanel: React.FC = () => { - {getCompatibleProperties(true).filter(prop => prop.name && prop.name.trim()).map((prop) => ( - - {prop.name} ({prop.type}) - - ))} + {getCompatibleProperties(true) + .filter((prop) => prop.name && prop.name.trim()) + .map((prop) => ( + + {prop.name} ({prop.type}) + + ))} - {!yAxisProperty && getCompatibleProperties(true).length === 0 && ( -

- 沒有可用的數字類型屬性,將使用計數模式 -

- )} + {!yAxisProperty && + getCompatibleProperties(true).length === 0 && ( +

+ 沒有可用的數字類型屬性,將使用計數模式 +

+ )} {/* 聚合函數 */} @@ -374,16 +425,30 @@ export const SettingsPanel: React.FC = () => { - - {(!yAxisProperty || getCompatibleProperties(true).length === 0) && ( + {(!yAxisProperty || + getCompatibleProperties(true).length === 0) && (

沒有 Y 軸屬性時僅支援計數聚合函數

@@ -412,11 +478,17 @@ export const SettingsPanel: React.FC = () => { - {getCompatibleProperties().filter(prop => prop.name && prop.name.trim()).map((prop) => ( - - {prop.name} ({prop.type}) - - ))} + {getCompatibleProperties() + .filter((prop) => prop.name && prop.name.trim()) + .map((prop) => ( + + {prop.name} ({prop.type}) + + ))} @@ -448,8 +520,12 @@ export const SettingsPanel: React.FC = () => { 篩選結果

- 已設定 {filterGroups.length} 個篩選組, - 包含 {filterGroups.reduce((total, group) => total + group.conditions.length, 0)} 個條件 + 已設定 {filterGroups.length} 個篩選組, 包含{" "} + {filterGroups.reduce( + (total, group) => total + group.conditions.length, + 0 + )}{" "} + 個條件

) : ( @@ -468,7 +544,7 @@ export const SettingsPanel: React.FC = () => { disabled={databaseProperties.length === 0} > - {filterGroups.length > 0 ? '編輯篩選條件' : '設定篩選條件'} + {filterGroups.length > 0 ? "編輯篩選條件" : "設定篩選條件"} @@ -485,7 +561,7 @@ export const SettingsPanel: React.FC = () => { 生成中... ) : ( - '生成圖表' + "生成圖表" )} @@ -524,5 +600,5 @@ export const SettingsPanel: React.FC = () => { onFilterGroupsChange={setFilterGroups} /> - ) -} + ); +}; diff --git a/frontend/src/lib/filter-validation.ts b/frontend/src/lib/filter-validation.ts index d2a025b..a43b9ce 100644 --- a/frontend/src/lib/filter-validation.ts +++ b/frontend/src/lib/filter-validation.ts @@ -16,7 +16,7 @@ export function validateFilterCondition( if (property.type === "date" && condition.operator === "between") { // 更安全的日期解析 const parseDate = ( - dateStr: string | number | boolean | undefined + dateStr: string | number | boolean | string[] | undefined ): Date | null => { if (!dateStr || typeof dateStr !== "string") return null; const date = new Date(dateStr); diff --git a/package.json b/package.json index a18c5e6..b218ca8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "notion-chart-generator", - "version": "1.1.6", + "version": "1.1.7", "description": "Notion Chart Generator - 現代化的資料視覺化工具", "main": "index.js", "directories": {