Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dist/assets/index-5380a742.css

Large diffs are not rendered by default.

1,349 changes: 1,349 additions & 0 deletions dist/assets/index-813fbcca.js

Large diffs are not rendered by default.

Binary file added dist/favicon.ico
Binary file not shown.
43 changes: 43 additions & 0 deletions dist/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="refine | Build your React-based CRUD applications, without constraints."
/>
<meta
data-rh="true"
property="og:image"
content="https://refine.dev/img/refine_social.png"
/>
<meta
data-rh="true"
name="twitter:image"
content="https://refine.dev/img/refine_social.png"
/>
<title>
refine - Build your React-based CRUD applications, without constraints.
</title>
<script type="module" crossorigin src="/assets/index-813fbcca.js"></script>
<link rel="stylesheet" href="/assets/index-5380a742.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>

<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.

You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.

To begin the development, run `npm dev` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
166 changes: 166 additions & 0 deletions src/components/tasks/filter/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import React, { useMemo } from 'react'
import { Select, DatePicker, Button, Space, Card } from 'antd'
import { FilterOutlined, ClearOutlined } from '@ant-design/icons'
import { useSelect } from '@refinedev/antd'
import { GetFieldsFromList } from '@refinedev/nestjs-query'
import { UsersSelectQuery, TaskStagesSelectQuery } from '@/graphql/types'
import { USERS_SELECT_QUERY, TASK_STAGES_SELECT_QUERY } from '@/graphql/queries'
import SelectOptionWithAvatar from '@/components/select-option-with-avatar'
import dayjs, { Dayjs } from 'dayjs'
import { RangePickerProps } from 'antd/es/date-picker'
Comment on lines +9 to +10
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dayjs is imported but never used in this component (only Dayjs is used as a type). Remove the unused default import to avoid lint/TS warnings (or switch to import type { Dayjs } from 'dayjs' if your tooling supports it).

Suggested change
import dayjs, { Dayjs } from 'dayjs'
import { RangePickerProps } from 'antd/es/date-picker'
import type { Dayjs } from 'dayjs'
import type { RangePickerProps } from 'antd/es/date-picker'

Copilot uses AI. Check for mistakes.

export interface TaskFilterState {
userIds: string[]
stageIds: string[]
dueDateRange: [Dayjs | null, Dayjs | null] | null
}

interface TaskFilterProps {
filters: TaskFilterState
onFiltersChange: (filters: TaskFilterState) => void
}

const { RangePicker } = DatePicker

type UserOption = {
value: string
label: string
avatarUrl?: string | null
}

export const TaskFilter: React.FC<TaskFilterProps> = ({ filters, onFiltersChange }) => {
const { selectProps: usersSelectProps } = useSelect<GetFieldsFromList<UsersSelectQuery>>({
resource: 'users',
meta: {
gqlQuery: USERS_SELECT_QUERY,
},
optionLabel: 'name',
})

const { selectProps: stagesSelectProps } = useSelect<GetFieldsFromList<TaskStagesSelectQuery>>({
resource: 'taskStages',
meta: {
gqlQuery: TASK_STAGES_SELECT_QUERY,
},
optionLabel: 'title',
})

const userOptions = useMemo(() => {
if (!usersSelectProps?.options) {
return []
}
return usersSelectProps.options.map((user) => ({
value: user.value,
label: (
<SelectOptionWithAvatar
name={user.label as string}
avatarUrl={(user as UserOption).avatarUrl ?? undefined}
/>
Comment on lines +52 to +58
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userOptions builds avatarUrl via (user as any).avatarUrl, but useSelect().selectProps.options typically only contains { value, label }, so avatars will likely always be undefined and the any cast hides it. Prefer mapping from the useSelect queryResult.data?.data nodes (which include avatarUrl in USERS_SELECT_QUERY) to build options with strongly typed fields, similar to the pattern used elsewhere when rendering SelectOptionWithAvatar.

Copilot uses AI. Check for mistakes.
),
}))
}, [usersSelectProps])

const handleUserChange = (value: string[]) => {
onFiltersChange({
...filters,
userIds: value,
})
}

const handleStageChange = (value: string[]) => {
onFiltersChange({
...filters,
stageIds: value,
})
}

const handleDateRangeChange: RangePickerProps['onChange'] = (dates) => {
onFiltersChange({
...filters,
dueDateRange: dates as [Dayjs | null, Dayjs | null] | null,
})
}

const handleClearFilters = () => {
onFiltersChange({
userIds: [],
stageIds: [],
dueDateRange: null,
})
}

const hasActiveFilters = filters.userIds.length > 0 ||
filters.stageIds.length > 0 ||
filters.dueDateRange !== null

return (
<Card
size="small"
style={{ marginBottom: 16 }}
title={
<Space>
<FilterOutlined />
<span>筛选条件</span>
</Space>
}
extra={
hasActiveFilters ? (
<Button
type="text"
icon={<ClearOutlined />}
onClick={handleClearFilters}
>
清空筛选
</Button>
) : null
}
>
<Space wrap size="middle" style={{ width: '100%' }}>
<div style={{ minWidth: 200 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 12, color: '#666' }}>
负责人
</label>
<Select
mode="multiple"
placeholder="选择负责人"
style={{ width: '100%' }}
value={filters.userIds}
onChange={handleUserChange}
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

负责人 <Select> does not spread usersSelectProps, so you lose useSelect-provided props like loading state, search handlers, and any configured filtering/pagination behavior. Consider spreading {...usersSelectProps} (and overriding options with your custom userOptions), to keep behavior consistent with other useSelect usages.

Suggested change
onChange={handleUserChange}
onChange={handleUserChange}
{...usersSelectProps}

Copilot uses AI. Check for mistakes.
options={userOptions}
allowClear
/>
</div>

<div style={{ minWidth: 200 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 12, color: '#666' }}>
阶段
</label>
<Select
mode="multiple"
placeholder="选择阶段"
style={{ width: '100%' }}
value={filters.stageIds}
onChange={handleStageChange}
options={stagesSelectProps?.options}
allowClear
/>
</div>

<div style={{ minWidth: 280 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 12, color: '#666' }}>
截止日期范围
</label>
<RangePicker
style={{ width: '100%' }}
value={filters.dueDateRange}
onChange={handleDateRangeChange}
placeholder={['开始日期', '结束日期']}
allowClear
/>
</div>
</Space>
</Card>
)
}

export default TaskFilter
48 changes: 44 additions & 4 deletions src/pages/tasks/list.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { KanbanColumnSkeleton, ProjectCardSkeleton } from '@/components'
import TaskFilter, { TaskFilterState } from '@/components/tasks/filter'
import { KanbanAddCardButton } from '@/components/tasks/kanban/add-card-button'
import { KanbanBoardContainer, KanbanBoard } from '@/components/tasks/kanban/board'
import { ProjectCardMemo } from '@/components/tasks/kanban/card'
Expand All @@ -10,6 +11,7 @@ import { TaskStagesQuery, TasksQuery } from '@/graphql/types'
import { DragEndEvent } from '@dnd-kit/core'
import { useList, useNavigation, useUpdate } from '@refinedev/core'
import { GetFieldsFromList } from '@refinedev/nestjs-query'
import dayjs from 'dayjs'
import React from 'react'

type Task = GetFieldsFromList<TasksQuery>
Expand All @@ -18,6 +20,12 @@ type TaskStage = GetFieldsFromList<TaskStagesQuery> & { tasks: Task[] }
const List = ({ children }: React.PropsWithChildren) => {
const { replace } = useNavigation()

const [filters, setFilters] = React.useState<TaskFilterState>({
userIds: [],
stageIds: [],
dueDateRange: null,
})

const { data: stages, isLoading: isLoadingStages } = useList<TaskStage>({
resource: 'taskStages',
filters: [
Expand Down Expand Up @@ -58,26 +66,54 @@ const List = ({ children }: React.PropsWithChildren) => {

const { mutate: updateTask } = useUpdate();

const filteredTasks = React.useMemo(() => {
if (!tasks?.data) return []

return tasks.data.filter((task) => {
if (filters.userIds.length > 0) {
const taskUserIds = task.users?.map((user) => user.id) || []
const hasMatchingUser = filters.userIds.some((userId) => taskUserIds.includes(userId))
if (!hasMatchingUser) return false
}

if (filters.stageIds.length > 0) {
const taskStageId = task.stageId?.toString() || 'unassigned'
if (!filters.stageIds.includes(taskStageId)) return false
}

if (filters.dueDateRange) {
const [startDate, endDate] = filters.dueDateRange
if (!task.dueDate) return false

const taskDueDate = dayjs(task.dueDate)
if (startDate && taskDueDate.isBefore(startDate, 'day')) return false
if (endDate && taskDueDate.isAfter(endDate, 'day')) return false
}

return true
})
}, [tasks, filters])

const taskStages = React.useMemo(() => {
if (!tasks?.data || !stages?.data) {
if (!filteredTasks.length || !stages?.data) {
return {
unassignedStage: [],
stages: []
}
}
Comment on lines +98 to 103
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

taskStages memo returns { unassignedStage, stages: [] } in the empty state but the non-empty state returns { unassignedStage, columns: grouped }. Downstream JSX reads taskStages.columns, so when filteredTasks is empty (e.g. filters exclude all tasks) stage columns disappear entirely. Return a consistent shape (use columns in both branches), and avoid early-returning on !filteredTasks.length so the board can still render all stages with zero tasks.

Copilot uses AI. Check for mistakes.

const unassignedStage = tasks.data.filter((task) => task.stageId === null)
const unassignedStage = filteredTasks.filter((task) => task.stageId === null)

const grouped: TaskStage[] = stages.data.map((stage) => ({
...stage,
tasks: tasks.data.filter((task) => task.stageId?.toString() === stage.id)
tasks: filteredTasks.filter((task) => task.stageId?.toString() === stage.id)
}))

return {
unassignedStage,
columns: grouped
}
}, [stages, tasks])
}, [stages, filteredTasks])

const handleAddCard = (args: { stageId: string}) => {
const path = args.stageId === 'unassigned'
Expand Down Expand Up @@ -118,6 +154,10 @@ const List = ({ children }: React.PropsWithChildren) => {

return (
<>
<TaskFilter
filters={filters}
onFiltersChange={setFilters}
/>
<KanbanBoardContainer>
<KanbanBoard onDragEnd={handleOnDragEnd}>
<KanbanColumn
Expand Down