@tuturuuu/ui 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/biome.json +1 -1
  3. package/package.json +73 -71
  4. package/src/components/ui/accordion.tsx +1 -1
  5. package/src/components/ui/breadcrumb.tsx +1 -1
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +4 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +239 -33
  8. package/src/components/ui/calendar-app/components/load-smart-scheduling-tasks.tsx +143 -0
  9. package/src/components/ui/calendar-app/components/priority-view.tsx +10 -3
  10. package/src/components/ui/calendar-app/components/tasks-sidebar.tsx +4 -116
  11. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +67 -2
  12. package/src/components/ui/calendar.tsx +1 -1
  13. package/src/components/ui/carousel.tsx +1 -1
  14. package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +1 -1
  15. package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +1 -1
  16. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +1 -1
  17. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +1 -1
  18. package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +1 -1
  19. package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +1 -1
  20. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +2 -2
  21. package/src/components/ui/chat/chat-agent-details-utils.test.ts +1 -1
  22. package/src/components/ui/chat/chat-agent-details-utils.tsx +1 -1
  23. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +2 -2
  24. package/src/components/ui/checkbox.tsx +1 -1
  25. package/src/components/ui/color-picker.tsx +1 -1
  26. package/src/components/ui/command.tsx +1 -1
  27. package/src/components/ui/context-menu.tsx +5 -1
  28. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +3 -0
  29. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +19 -0
  30. package/src/components/ui/custom/combobox.test.tsx +195 -0
  31. package/src/components/ui/custom/combobox.tsx +273 -156
  32. package/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx +5 -13
  33. package/src/components/ui/custom/facebook-mockup/facebook-mockup.tsx +7 -1
  34. package/src/components/ui/custom/facebook-mockup/form.tsx +1 -1
  35. package/src/components/ui/custom/facebook-mockup/image-upload-field.tsx +1 -1
  36. package/src/components/ui/custom/facebook-mockup/preview.tsx +1 -1
  37. package/src/components/ui/custom/settings-dialog-shell.tsx +2 -1
  38. package/src/components/ui/custom/theme-toggle.tsx +1 -1
  39. package/src/components/ui/custom/workspace-select.tsx +8 -3
  40. package/src/components/ui/dialog.test.tsx +52 -0
  41. package/src/components/ui/dialog.tsx +6 -2
  42. package/src/components/ui/dropdown-menu.tsx +5 -1
  43. package/src/components/ui/finance/debts/debt-loan-form.tsx +12 -5
  44. package/src/components/ui/finance/debts/debt-loan-summary.tsx +3 -2
  45. package/src/components/ui/finance/debts/debts-page.test.tsx +54 -5
  46. package/src/components/ui/finance/debts/debts-page.tsx +15 -2
  47. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +3 -5
  48. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +25 -5
  49. package/src/components/ui/finance/invoices/new-invoice-page.tsx +7 -2
  50. package/src/components/ui/finance/invoices/standard-invoice.tsx +4 -2
  51. package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -2
  52. package/src/components/ui/finance/invoices/utils.ts +3 -1
  53. package/src/components/ui/finance/transactions/form-content-dialog.tsx +3 -0
  54. package/src/components/ui/finance/transactions/form-types.ts +1 -0
  55. package/src/components/ui/finance/transactions/form.tsx +2 -0
  56. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +2 -0
  57. package/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx +1 -1
  58. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +1 -4
  59. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +3 -0
  60. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -1
  61. package/src/components/ui/finance/wallets/form.test.tsx +51 -3
  62. package/src/components/ui/finance/wallets/form.tsx +15 -4
  63. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  64. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +4 -2
  65. package/src/components/ui/finance/wallets/wallets-data-table.tsx +1 -0
  66. package/src/components/ui/finance/wallets/wallets-page.tsx +5 -2
  67. package/src/components/ui/input-otp.tsx +1 -1
  68. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +28 -39
  69. package/src/components/ui/legacy/calendar/calendar-cell.tsx +2 -0
  70. package/src/components/ui/legacy/calendar/calendar-content.tsx +10 -6
  71. package/src/components/ui/legacy/calendar/calendar-header.tsx +23 -3
  72. package/src/components/ui/legacy/calendar/calendar-loading-skeleton.tsx +135 -0
  73. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +175 -237
  74. package/src/components/ui/legacy/calendar/event-card.test.tsx +177 -0
  75. package/src/components/ui/legacy/calendar/event-card.tsx +220 -131
  76. package/src/components/ui/legacy/calendar/event-modal.tsx +17 -17
  77. package/src/components/ui/legacy/calendar/event-provider-display.tsx +69 -0
  78. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +86 -4
  79. package/src/components/ui/legacy/calendar/smart-calendar.tsx +32 -2
  80. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +19 -10
  81. package/src/components/ui/navigation-menu.tsx +1 -1
  82. package/src/components/ui/pagination.tsx +1 -1
  83. package/src/components/ui/radio-group.tsx +1 -1
  84. package/src/components/ui/select.tsx +5 -1
  85. package/src/components/ui/sheet.tsx +1 -1
  86. package/src/components/ui/sidebar.tsx +1 -1
  87. package/src/components/ui/storefront/cart-popover.tsx +61 -0
  88. package/src/components/ui/storefront/cart-summary-parts.tsx +290 -0
  89. package/src/components/ui/storefront/cart-summary.tsx +93 -154
  90. package/src/components/ui/storefront/checkout-overlay.tsx +4 -5
  91. package/src/components/ui/storefront/listing-card.tsx +1 -1
  92. package/src/components/ui/storefront/merch-sections.tsx +70 -0
  93. package/src/components/ui/storefront/product-detail.tsx +1 -1
  94. package/src/components/ui/storefront/storefront-surface.test.tsx +106 -11
  95. package/src/components/ui/storefront/storefront-surface.tsx +101 -166
  96. package/src/components/ui/storefront/types.ts +4 -0
  97. package/src/components/ui/storefront/utils.ts +6 -0
  98. package/src/components/ui/text-editor/__tests__/extensions.test.ts +123 -0
  99. package/src/components/ui/text-editor/background-color-extension.ts +62 -0
  100. package/src/components/ui/text-editor/color-controls.tsx +284 -0
  101. package/src/components/ui/text-editor/editor.tsx +69 -14
  102. package/src/components/ui/text-editor/extensions.ts +8 -2
  103. package/src/components/ui/text-editor/highlight-extension.ts +22 -0
  104. package/src/components/ui/text-editor/tool-bar.tsx +9 -16
  105. package/src/components/ui/toast.tsx +1 -1
  106. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +270 -0
  107. package/src/components/ui/tu-do/boards/board-public-link-section.tsx +231 -0
  108. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +222 -109
  109. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +112 -43
  110. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +2 -0
  111. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-move.ts +5 -0
  112. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +3 -0
  113. package/src/components/ui/tu-do/boards/boardId/kanban/data/kanban-deadline-query.ts +50 -2
  114. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/__tests__/column-reorder.test.ts +17 -0
  115. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/column-reorder.ts +4 -1
  116. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +38 -9
  117. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-order.ts +2 -8
  118. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-sort-key.ts +47 -0
  119. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +81 -30
  120. package/src/components/ui/tu-do/boards/boardId/kanban/planner/__tests__/kanban-planner-island.test.tsx +380 -0
  121. package/src/components/ui/tu-do/boards/boardId/kanban/planner/kanban-planner-dialog.tsx +204 -0
  122. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-digest-panel.tsx +61 -0
  123. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-item-strip.tsx +54 -0
  124. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-plan-toolbar.tsx +251 -0
  125. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-scope-badge.tsx +27 -0
  126. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-section.tsx +58 -0
  127. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-share-dialog.tsx +238 -0
  128. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-target-controls.tsx +143 -0
  129. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-utils.ts +65 -0
  130. package/src/components/ui/tu-do/boards/boardId/kanban/planner/use-kanban-planner-state.ts +234 -0
  131. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +397 -2
  132. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +103 -13
  133. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +443 -19
  134. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +94 -32
  135. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +213 -106
  136. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +26 -4
  137. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +5 -2
  138. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +3 -0
  139. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +3 -0
  140. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +191 -28
  141. package/src/components/ui/tu-do/boards/boardId/task-filter.test.tsx +152 -0
  142. package/src/components/ui/tu-do/boards/boardId/task-filter.tsx +555 -545
  143. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +7 -0
  144. package/src/components/ui/tu-do/boards/share-section.tsx +100 -0
  145. package/src/components/ui/tu-do/drafts/draft-convert-dialog.tsx +10 -12
  146. package/src/components/ui/tu-do/drafts/drafts-page.tsx +33 -16
  147. package/src/components/ui/tu-do/initiatives/task-initiatives-client.tsx +56 -88
  148. package/src/components/ui/tu-do/my-tasks/my-tasks-content.tsx +26 -2
  149. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +55 -8
  150. package/src/components/ui/tu-do/notes/note-edit-dialog.tsx +1 -4
  151. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +25 -0
  152. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +341 -38
  153. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +253 -0
  154. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +203 -2
  155. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +17 -0
  156. package/src/components/ui/tu-do/shared/__tests__/task-legacy-route-recovery.test.tsx +16 -0
  157. package/src/components/ui/tu-do/shared/board-client.tsx +2 -7
  158. package/src/components/ui/tu-do/shared/board-config-storage.ts +7 -1
  159. package/src/components/ui/tu-do/shared/board-header.tsx +464 -975
  160. package/src/components/ui/tu-do/shared/board-layout-settings.tsx +165 -136
  161. package/src/components/ui/tu-do/shared/board-switcher.tsx +209 -217
  162. package/src/components/ui/tu-do/shared/board-views.tsx +587 -75
  163. package/src/components/ui/tu-do/shared/list-view.tsx +227 -1
  164. package/src/components/ui/tu-do/shared/recycle-bin-panel.tsx +142 -94
  165. package/src/components/ui/tu-do/shared/special-task-list-pins.ts +51 -0
  166. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +28 -0
  167. package/src/components/ui/tu-do/shared/task-edit-dialog/field-diff-viewer.tsx +3 -2
  168. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.test.tsx +91 -0
  169. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.tsx +123 -78
  170. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +7 -1
  171. package/src/components/ui/tu-do/shared/task-edit-dialog/task-snapshot-dialog.tsx +8 -3
  172. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +2 -1
  173. package/src/components/ui/tu-do/shared/task-legacy-route-recovery.tsx +2 -9
  174. package/src/declarations.d.ts +1 -0
  175. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +322 -2
  176. package/src/hooks/__tests__/use-calendar-sync.test.tsx +446 -0
  177. package/src/hooks/use-calendar-sync.tsx +247 -243
  178. package/src/hooks/use-calendar.tsx +323 -138
  179. package/src/hooks/use-task-actions.ts +24 -0
  180. package/src/hooks/use-user-workspace-config.ts +75 -0
  181. package/src/hooks/use-workspace-currency.ts +8 -3
  182. package/src/hooks/useBoardRealtimeEventHandler.ts +11 -0
@@ -4,7 +4,6 @@ import { useQuery } from '@tanstack/react-query';
4
4
  import {
5
5
  Building2,
6
6
  Calendar as CalendarIcon,
7
- Check,
8
7
  Filter,
9
8
  Flag,
10
9
  Globe2,
@@ -30,25 +29,17 @@ import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
30
29
  import { Avatar, AvatarFallback, AvatarImage } from '@tuturuuu/ui/avatar';
31
30
  import { Badge } from '@tuturuuu/ui/badge';
32
31
  import { Button } from '@tuturuuu/ui/button';
33
- import { Calendar } from '@tuturuuu/ui/calendar';
34
32
  import { Checkbox } from '@tuturuuu/ui/checkbox';
35
- import {
36
- DropdownMenu,
37
- DropdownMenuContent,
38
- DropdownMenuItem,
39
- DropdownMenuLabel,
40
- DropdownMenuSeparator,
41
- DropdownMenuSub,
42
- DropdownMenuSubContent,
43
- DropdownMenuSubTrigger,
44
- DropdownMenuTrigger,
45
- } from '@tuturuuu/ui/dropdown-menu';
33
+ import { Combobox } from '@tuturuuu/ui/custom/combobox';
46
34
  import { useWorkspaceMembers } from '@tuturuuu/ui/hooks/use-workspace-members';
35
+ import { Input } from '@tuturuuu/ui/input';
36
+ import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
47
37
  import { ScrollArea } from '@tuturuuu/ui/scroll-area';
38
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
48
39
  import { cn } from '@tuturuuu/utils/format';
49
40
  import { getInitials } from '@tuturuuu/utils/name-helper';
50
41
  import { useTranslations } from 'next-intl';
51
- import { useMemo, useState } from 'react';
42
+ import { type ReactNode, useMemo, useState } from 'react';
52
43
  import type {
53
44
  SortOption,
54
45
  TaskAssignee,
@@ -192,6 +183,85 @@ const PRIORITIES: { value: TaskPriority; labelKey: string; color: string }[] = [
192
183
  { value: 'low', labelKey: 'tasks.priority_low', color: 'text-dynamic-gray' },
193
184
  ];
194
185
 
186
+ function formatShortDate(date: Date) {
187
+ return new Intl.DateTimeFormat(undefined, {
188
+ day: 'numeric',
189
+ month: 'short',
190
+ }).format(date);
191
+ }
192
+
193
+ function formatDateInputValue(date: Date | undefined) {
194
+ if (!date) return '';
195
+
196
+ const year = date.getFullYear();
197
+ const month = String(date.getMonth() + 1).padStart(2, '0');
198
+ const day = String(date.getDate()).padStart(2, '0');
199
+
200
+ return `${year}-${month}-${day}`;
201
+ }
202
+
203
+ function parseDateInputValue(value: string) {
204
+ if (!value) return undefined;
205
+
206
+ const [year, month, day] = value.split('-').map(Number);
207
+ if (!year || !month || !day) return undefined;
208
+
209
+ return new Date(year, month - 1, day);
210
+ }
211
+
212
+ function FilterPickerField({
213
+ badge,
214
+ children,
215
+ icon,
216
+ label,
217
+ }: {
218
+ badge?: ReactNode;
219
+ children: ReactNode;
220
+ icon: ReactNode;
221
+ label: string;
222
+ }) {
223
+ return (
224
+ <div className="space-y-1.5">
225
+ <div className="flex min-w-0 items-center gap-2 text-muted-foreground text-xs">
226
+ {icon}
227
+ <span className="min-w-0 flex-1 truncate font-medium">{label}</span>
228
+ {badge}
229
+ </div>
230
+ {children}
231
+ </div>
232
+ );
233
+ }
234
+
235
+ function FilterSection({
236
+ badge,
237
+ children,
238
+ className,
239
+ icon,
240
+ testId,
241
+ title,
242
+ }: {
243
+ badge?: ReactNode;
244
+ children: ReactNode;
245
+ className?: string;
246
+ icon: ReactNode;
247
+ testId?: string;
248
+ title: string;
249
+ }) {
250
+ return (
251
+ <section
252
+ className={cn('rounded-md border bg-background p-2.5', className)}
253
+ data-testid={testId}
254
+ >
255
+ <div className="mb-2 flex min-w-0 items-center gap-2 text-muted-foreground text-xs">
256
+ {icon}
257
+ <span className="min-w-0 flex-1 truncate font-semibold">{title}</span>
258
+ {badge}
259
+ </div>
260
+ {children}
261
+ </section>
262
+ );
263
+ }
264
+
195
265
  export function TaskFilter({
196
266
  wsId,
197
267
  currentUserId,
@@ -320,59 +390,115 @@ export function TaskFilter({
320
390
  staleTime: 60_000,
321
391
  });
322
392
 
323
- const toggleLabel = (label: TaskLabel) => {
324
- const isSelected = filters.labels.some((l) => l.id === label.id);
393
+ const sourceScopeOptions = SOURCE_SCOPE_OPTIONS.map((scope) => {
394
+ const Icon = SOURCE_SCOPE_ICONS[scope];
395
+
396
+ return {
397
+ value: scope,
398
+ label: t(`ws-tasks.filter_source_scope_${scope}` as any),
399
+ icon: <Icon className="h-4 w-4 text-muted-foreground" />,
400
+ };
401
+ });
402
+
403
+ const sourceWorkspaceOptions = sourceWorkspaces.map((workspace) => ({
404
+ value: workspace.id,
405
+ label: workspace.name ?? workspace.id,
406
+ icon: <Building2 className="h-4 w-4 text-muted-foreground" />,
407
+ }));
408
+
409
+ const sourceBoardOptions = sourceBoards.map((board) => ({
410
+ value: board.id,
411
+ label: board.name ?? t('common.untitled'),
412
+ description: board.workspaceName,
413
+ searchValue: `${board.name ?? ''} ${board.workspaceName}`,
414
+ icon: <LayoutDashboard className="h-4 w-4 text-muted-foreground" />,
415
+ }));
416
+
417
+ const assigneeOptions = availableAssignees.map((assignee) => {
418
+ const label =
419
+ assignee.display_name ||
420
+ assignee.email ||
421
+ assignee.id ||
422
+ t('common.untitled');
423
+
424
+ return {
425
+ value: assignee.id,
426
+ label,
427
+ description:
428
+ assignee.display_name && assignee.email ? assignee.email : undefined,
429
+ searchValue: `${label} ${assignee.email ?? ''}`,
430
+ icon: (
431
+ <Avatar className="h-5 w-5 border">
432
+ {assignee.avatar_url && <AvatarImage src={assignee.avatar_url} />}
433
+ <AvatarFallback className="font-medium text-[9px]">
434
+ {getInitials(label)}
435
+ </AvatarFallback>
436
+ </Avatar>
437
+ ),
438
+ };
439
+ });
440
+
441
+ const labelOptions = availableLabels.map((label) => ({
442
+ value: label.id,
443
+ label: label.name,
444
+ icon: (
445
+ <span
446
+ className="h-2.5 w-2.5 shrink-0 rounded-full"
447
+ style={getColorStyles(label.color)}
448
+ />
449
+ ),
450
+ }));
451
+
452
+ const projectOptions = availableProjects.map((project) => ({
453
+ value: project.id,
454
+ label: project.name,
455
+ icon: <Hash className="h-4 w-4 text-muted-foreground" />,
456
+ }));
457
+
458
+ const priorityOptions = PRIORITIES.map((priority) => ({
459
+ value: priority.value,
460
+ label: t(priority.labelKey as any),
461
+ icon: <Flag className={cn(priority.color, 'h-4 w-4')} />,
462
+ }));
463
+
464
+ const setLabelIds = (labelIds: string[]) => {
325
465
  onFiltersChange({
326
466
  ...filters,
327
- labels: isSelected
328
- ? filters.labels.filter((l) => l.id !== label.id)
329
- : [...filters.labels, label],
467
+ labels: availableLabels.filter((label) => labelIds.includes(label.id)),
330
468
  });
331
469
  };
332
470
 
333
- const toggleAssignee = (assignee: TaskAssignee) => {
334
- const isSelected = filters.assignees.some((a) => a.id === assignee.id);
335
- const isCurrentUser = currentUserId === assignee.id;
336
-
337
- const newAssignees = isSelected
338
- ? filters.assignees.filter((a) => a.id !== assignee.id)
339
- : [...filters.assignees, assignee];
340
-
341
- // Determine if "Assigned to me" should be checked:
342
- // - If toggling current user: sync with their selection state
343
- // - If adding a non-current-user: uncheck "Assigned to me"
344
- // - If removing someone while current user remains: keep "Assigned to me" if only current user left
345
- const shouldIncludeMyTasks = isCurrentUser
346
- ? !isSelected
347
- : newAssignees.length === 1 && newAssignees[0]?.id === currentUserId;
471
+ const setAssigneeIds = (assigneeIds: string[]) => {
472
+ const newAssignees = availableAssignees.filter((assignee) =>
473
+ assigneeIds.includes(assignee.id)
474
+ );
475
+ const shouldIncludeMyTasks =
476
+ newAssignees.length === 1 && newAssignees[0]?.id === currentUserId;
348
477
 
349
478
  onFiltersChange({
350
479
  ...filters,
351
480
  assignees: newAssignees,
352
481
  includeMyTasks: shouldIncludeMyTasks,
353
- // Auto-deselect "Unassigned" when selecting any assignee
354
482
  includeUnassigned:
355
483
  newAssignees.length > 0 ? false : filters.includeUnassigned,
356
484
  });
357
485
  };
358
486
 
359
- const toggleProject = (project: TaskProject) => {
360
- const isSelected = filters.projects.some((p) => p.id === project.id);
487
+ const setProjectIds = (projectIds: string[]) => {
361
488
  onFiltersChange({
362
489
  ...filters,
363
- projects: isSelected
364
- ? filters.projects.filter((p) => p.id !== project.id)
365
- : [...filters.projects, project],
490
+ projects: availableProjects.filter((project) =>
491
+ projectIds.includes(project.id)
492
+ ),
366
493
  });
367
494
  };
368
495
 
369
- const togglePriority = (priority: TaskPriority) => {
370
- const isSelected = filters.priorities.includes(priority);
496
+ const setPriorityValues = (priorities: string[]) => {
371
497
  onFiltersChange({
372
498
  ...filters,
373
- priorities: isSelected
374
- ? filters.priorities.filter((p) => p !== priority)
375
- : [...filters.priorities, priority],
499
+ priorities: priorities.filter((priority): priority is TaskPriority =>
500
+ PRIORITIES.some((option) => option.value === priority)
501
+ ),
376
502
  });
377
503
  };
378
504
 
@@ -389,38 +515,33 @@ export function TaskFilter({
389
515
  });
390
516
  };
391
517
 
392
- const toggleSourceWorkspace = (workspace: InternalApiWorkspaceSummary) => {
393
- const isSelected = selectedSourceWorkspaceIds.includes(workspace.id);
394
- const nextWorkspaceIds = isSelected
395
- ? selectedSourceWorkspaceIds.filter((id) => id !== workspace.id)
396
- : [...selectedSourceWorkspaceIds, workspace.id];
397
- const deselectedBoardIds = new Set(
398
- sourceBoards
399
- .filter((board) => board.workspaceId === workspace.id)
400
- .map((board) => board.id)
401
- );
518
+ const setSourceWorkspaceIds = (nextWorkspaceIds: string[]) => {
519
+ const selectedWorkspaces = new Set(nextWorkspaceIds);
402
520
 
403
521
  onFiltersChange({
404
522
  ...filters,
405
523
  sourceScope: 'external_specific',
406
524
  sourceWorkspaceIds: nextWorkspaceIds,
407
- sourceBoardIds: isSelected
408
- ? selectedSourceBoardIds.filter((id) => !deselectedBoardIds.has(id))
409
- : selectedSourceBoardIds,
525
+ sourceBoardIds: selectedSourceBoardIds.filter((boardId) => {
526
+ const board = sourceBoards.find((item) => item.id === boardId);
527
+ return !board || selectedWorkspaces.has(board.workspaceId);
528
+ }),
410
529
  });
411
530
  };
412
531
 
413
- const toggleSourceBoard = (board: SourceBoardOption) => {
414
- const isSelected = selectedSourceBoardIds.includes(board.id);
532
+ const setSourceBoardIds = (nextBoardIds: string[]) => {
533
+ const workspaceIds = new Set(selectedSourceWorkspaceIds);
534
+ for (const board of sourceBoards.filter((board) =>
535
+ nextBoardIds.includes(board.id)
536
+ )) {
537
+ workspaceIds.add(board.workspaceId);
538
+ }
539
+
415
540
  onFiltersChange({
416
541
  ...filters,
417
542
  sourceScope: 'external_specific',
418
- sourceBoardIds: isSelected
419
- ? selectedSourceBoardIds.filter((id) => id !== board.id)
420
- : [...selectedSourceBoardIds, board.id],
421
- sourceWorkspaceIds: selectedSourceWorkspaceIds.includes(board.workspaceId)
422
- ? selectedSourceWorkspaceIds
423
- : [...selectedSourceWorkspaceIds, board.workspaceId],
543
+ sourceBoardIds: nextBoardIds,
544
+ sourceWorkspaceIds: Array.from(workspaceIds),
424
545
  });
425
546
  };
426
547
 
@@ -477,510 +598,399 @@ export function TaskFilter({
477
598
  selectedSourceWorkspaceIds.length + selectedSourceBoardIds.length
478
599
  )
479
600
  : 0);
601
+ const sourceFilterCount = isSourceFilterActive
602
+ ? Math.max(
603
+ 1,
604
+ selectedSourceWorkspaceIds.length + selectedSourceBoardIds.length
605
+ )
606
+ : 0;
607
+ const quickFilterCount =
608
+ (filters.includeMyTasks ? 1 : 0) + (filters.includeUnassigned ? 1 : 0);
609
+ const peopleFilterCount = assigneeCount;
610
+ const detailFilterCount =
611
+ filters.labels.length + filters.projects.length + filters.priorities.length;
612
+ const dueDateSummary = filters.dueDateRange
613
+ ? [
614
+ filters.dueDateRange.from
615
+ ? formatShortDate(filters.dueDateRange.from)
616
+ : null,
617
+ filters.dueDateRange.to
618
+ ? formatShortDate(filters.dueDateRange.to)
619
+ : null,
620
+ ]
621
+ .filter(Boolean)
622
+ .join(' - ') || t('common.due_date')
623
+ : t('common.due_date');
480
624
 
481
625
  return (
482
626
  <div className="flex flex-wrap items-center gap-1 sm:gap-1.5">
483
- <DropdownMenu open={open} onOpenChange={setOpen}>
484
- <DropdownMenuTrigger asChild>
485
- <Button
486
- size="xs"
487
- variant="outline"
488
- className={cn(
489
- 'text-[10px] sm:text-xs',
490
- hasFilters && 'border-primary/50 bg-primary/5'
491
- )}
492
- >
493
- <Filter className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
494
- <span className="hidden sm:inline">{t('common.filters')}</span>
495
- {hasFilters && (
496
- <Badge
497
- variant="secondary"
498
- className="h-3.5 px-1 text-[9px] sm:h-4 sm:text-[10px]"
627
+ <Popover open={open} onOpenChange={setOpen}>
628
+ <Tooltip delayDuration={0}>
629
+ <TooltipTrigger asChild>
630
+ <PopoverTrigger asChild>
631
+ <Button
632
+ size="xs"
633
+ variant="outline"
634
+ aria-label={t('common.filters')}
635
+ className={cn(
636
+ 'relative h-7 w-7 px-0 text-muted-foreground transition-colors hover:text-foreground sm:h-8 sm:w-8',
637
+ hasFilters && 'border-primary/50 bg-primary/5 text-foreground'
638
+ )}
499
639
  >
500
- {filterCount}
501
- </Badge>
502
- )}
503
- </Button>
504
- </DropdownMenuTrigger>
505
- <DropdownMenuContent className="w-70 sm:w-[320px]" align="start">
506
- <ScrollArea className="max-h-[70vh] sm:max-h-100">
507
- {/* My Tasks */}
508
- {currentUserId && (
509
- <>
510
- <DropdownMenuLabel className="flex items-center gap-2 py-2 font-semibold text-muted-foreground text-xs uppercase tracking-wide">
511
- <User className="h-3.5 w-3.5" />
512
- {t('common.quick_filters')}
513
- </DropdownMenuLabel>
514
- <div className="space-y-1 px-2 pb-2">
515
- <label className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-sm hover:bg-accent">
516
- <Checkbox
517
- checked={filters.includeMyTasks}
518
- onCheckedChange={(checked) => {
519
- const currentUser = availableAssignees.find(
520
- (a) => a.id === currentUserId
521
- );
522
-
523
- onFiltersChange({
524
- ...filters,
525
- includeMyTasks: !!checked,
526
- // Replace all assignees with only current user when checked
527
- assignees:
528
- checked && currentUser ? [currentUser] : [],
529
- // Auto-deselect "Unassigned" when selecting "Assigned to me"
530
- includeUnassigned: checked
531
- ? false
532
- : filters.includeUnassigned,
533
- });
534
- }}
535
- />
536
- <UserStar className="h-4 w-4 text-dynamic-yellow" />
537
- <span>{t('common.assigned_to_me')}</span>
538
- </label>
539
- <label className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-2 text-sm hover:bg-accent">
540
- <Checkbox
541
- checked={filters.includeUnassigned}
542
- onCheckedChange={(checked) =>
543
- onFiltersChange({
544
- ...filters,
545
- includeUnassigned: !!checked,
546
- // Auto-deselect all assignees when selecting "Unassigned"
547
- includeMyTasks: checked
548
- ? false
549
- : filters.includeMyTasks,
550
- assignees: checked ? [] : filters.assignees,
551
- })
552
- }
553
- />
554
- <UserX className="h-4 w-4 text-dynamic-red" />
555
- <span>{t('common.unassigned')}</span>
556
- </label>
557
- </div>
558
- <DropdownMenuSeparator />
559
- </>
560
- )}
561
-
562
- {/* Source Scope */}
563
- <DropdownMenuSub>
564
- <DropdownMenuSubTrigger className="gap-2 py-2.5">
565
- <ListFilter className="h-4 w-4 text-muted-foreground" />
566
- <span className="flex-1">
567
- {t('ws-tasks.filter_source_scope')}
568
- </span>
569
- {isSourceFilterActive && (
640
+ <Filter className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
641
+ {hasFilters && (
570
642
  <Badge
571
643
  variant="secondary"
572
- className="h-4 min-w-5 justify-center px-1 text-[10px]"
644
+ className="absolute -top-1 -right-1 h-4 min-w-4 justify-center px-1 text-[9px]"
573
645
  >
574
- {sourceScope === 'external_specific'
575
- ? Math.max(
576
- 1,
577
- selectedSourceWorkspaceIds.length +
578
- selectedSourceBoardIds.length
579
- )
580
- : 1}
646
+ {filterCount}
581
647
  </Badge>
582
648
  )}
583
- </DropdownMenuSubTrigger>
584
- <DropdownMenuSubContent className="w-76 p-0">
585
- <div className="p-1">
586
- {SOURCE_SCOPE_OPTIONS.map((scope) => {
587
- const Icon = SOURCE_SCOPE_ICONS[scope];
588
- return (
589
- <DropdownMenuItem
590
- key={scope}
591
- onClick={(event) => {
592
- event.preventDefault();
593
- setSourceScope(scope);
649
+ </Button>
650
+ </PopoverTrigger>
651
+ </TooltipTrigger>
652
+ <TooltipContent>{t('common.filters')}</TooltipContent>
653
+ </Tooltip>
654
+ <PopoverContent
655
+ className="max-h-[min(82dvh,40rem)] w-[min(26rem,calc(100vw-1rem))] overflow-hidden p-0"
656
+ align="end"
657
+ collisionPadding={8}
658
+ sideOffset={6}
659
+ >
660
+ <ScrollArea className="h-[min(76dvh,36rem)]">
661
+ <div className="space-y-3 p-3">
662
+ {currentUserId && (
663
+ <FilterSection
664
+ icon={<User className="h-3.5 w-3.5" />}
665
+ title={t('common.quick_filters')}
666
+ badge={
667
+ quickFilterCount ? (
668
+ <Badge variant="secondary">{quickFilterCount}</Badge>
669
+ ) : null
670
+ }
671
+ testId="task-filter-section-quick"
672
+ >
673
+ <div className="grid gap-1 sm:grid-cols-2">
674
+ <label className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
675
+ <Checkbox
676
+ checked={filters.includeMyTasks}
677
+ onCheckedChange={(checked) => {
678
+ const currentUser = availableAssignees.find(
679
+ (assignee) => assignee.id === currentUserId
680
+ );
681
+
682
+ onFiltersChange({
683
+ ...filters,
684
+ includeMyTasks: !!checked,
685
+ assignees:
686
+ checked && currentUser ? [currentUser] : [],
687
+ includeUnassigned: checked
688
+ ? false
689
+ : filters.includeUnassigned,
690
+ });
594
691
  }}
595
- className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
596
- >
597
- <Checkbox
598
- checked={sourceScope === scope}
599
- className="pointer-events-none"
600
- />
601
- <Icon className="h-4 w-4 text-muted-foreground" />
602
- <span className="flex-1 truncate font-medium text-sm">
603
- {t(`ws-tasks.filter_source_scope_${scope}` as any)}
604
- </span>
605
- </DropdownMenuItem>
606
- );
607
- })}
608
- </div>
609
-
610
- {sourceScope === 'external_specific' && (
611
- <>
612
- <DropdownMenuSeparator />
613
- <DropdownMenuLabel className="flex items-center gap-2 py-2 font-semibold text-muted-foreground text-xs uppercase tracking-wide">
614
- <Building2 className="h-3.5 w-3.5" />
615
- {t('ws-tasks.filter_workspaces')}
616
- </DropdownMenuLabel>
617
- <div className="max-h-48 overflow-y-auto px-1 pb-1">
618
- {sourceWorkspaces.length === 0 ? (
619
- <div className="px-3 py-2 text-muted-foreground text-xs">
620
- {t('ws-tasks.filter_no_workspaces_available')}
621
- </div>
622
- ) : (
623
- sourceWorkspaces.map((workspace) => (
624
- <DropdownMenuItem
625
- key={workspace.id}
626
- onClick={(event) => {
627
- event.preventDefault();
628
- toggleSourceWorkspace(workspace);
629
- }}
630
- className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-2"
631
- >
632
- <Checkbox
633
- checked={selectedSourceWorkspaceIds.includes(
634
- workspace.id
635
- )}
636
- className="pointer-events-none"
637
- />
638
- <Building2 className="h-4 w-4 text-muted-foreground" />
639
- <span className="truncate font-medium text-sm">
640
- {workspace.name}
641
- </span>
642
- </DropdownMenuItem>
643
- ))
644
- )}
645
- </div>
646
-
647
- <DropdownMenuLabel className="flex items-center gap-2 py-2 font-semibold text-muted-foreground text-xs uppercase tracking-wide">
648
- <LayoutDashboard className="h-3.5 w-3.5" />
649
- {t('ws-tasks.filter_boards')}
650
- </DropdownMenuLabel>
651
- <div className="max-h-56 overflow-y-auto px-1 pb-1">
652
- {selectedSourceWorkspaceIds.length === 0 ? (
653
- <div className="px-3 py-2 text-muted-foreground text-xs">
654
- {t('ws-tasks.filter_select_source_prompt')}
655
- </div>
656
- ) : sourceBoardsLoading ? (
657
- <div className="px-3 py-2 text-muted-foreground text-xs">
658
- {t('common.loading')}
659
- </div>
660
- ) : sourceBoards.length === 0 ? (
661
- <div className="px-3 py-2 text-muted-foreground text-xs">
662
- {t('ws-tasks.filter_no_boards_for_workspaces')}
663
- </div>
664
- ) : (
665
- sourceBoards.map((board) => (
666
- <DropdownMenuItem
667
- key={board.id}
668
- onClick={(event) => {
669
- event.preventDefault();
670
- toggleSourceBoard(board);
671
- }}
672
- className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-2"
673
- >
674
- <Checkbox
675
- checked={selectedSourceBoardIds.includes(
676
- board.id
677
- )}
678
- className="pointer-events-none"
679
- />
680
- <LayoutDashboard className="h-4 w-4 text-muted-foreground" />
681
- <div className="min-w-0 flex-1">
682
- <div className="truncate font-medium text-sm">
683
- {board.name}
684
- </div>
685
- <div className="truncate text-muted-foreground text-xs">
686
- {board.workspaceName}
687
- </div>
688
- </div>
689
- </DropdownMenuItem>
690
- ))
691
- )}
692
- </div>
693
- </>
694
- )}
695
- </DropdownMenuSubContent>
696
- </DropdownMenuSub>
697
-
698
- {/* Assignees */}
699
- <DropdownMenuSub>
700
- <DropdownMenuSubTrigger className="gap-2 py-2.5">
701
- <Users className="h-4 w-4 text-muted-foreground" />
702
- <span className="flex-1">{t('common.assignees')}</span>
703
- {filters.assignees.length > 0 && (
704
- <Badge
705
- variant="secondary"
706
- className="h-4 min-w-5 justify-center px-1 text-[10px]"
707
- >
708
- {filters.assignees.length}
709
- </Badge>
710
- )}
711
- </DropdownMenuSubTrigger>
712
- <DropdownMenuSubContent className="w-65 p-0">
713
- {availableAssignees.length === 0 ? (
714
- <div className="p-4 text-center text-muted-foreground text-sm">
715
- {t('common.no_members_found')}
692
+ />
693
+ <UserStar className="h-4 w-4 shrink-0 text-dynamic-yellow" />
694
+ <span className="min-w-0 truncate">
695
+ {t('common.assigned_to_me')}
696
+ </span>
697
+ </label>
698
+ <label className="flex min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent">
699
+ <Checkbox
700
+ checked={filters.includeUnassigned}
701
+ onCheckedChange={(checked) =>
702
+ onFiltersChange({
703
+ ...filters,
704
+ includeUnassigned: !!checked,
705
+ includeMyTasks: checked
706
+ ? false
707
+ : filters.includeMyTasks,
708
+ assignees: checked ? [] : filters.assignees,
709
+ })
710
+ }
711
+ />
712
+ <UserX className="h-4 w-4 shrink-0 text-dynamic-red" />
713
+ <span className="min-w-0 truncate">
714
+ {t('common.unassigned')}
715
+ </span>
716
+ </label>
716
717
  </div>
717
- ) : (
718
- <div className="max-h-60 overflow-y-auto">
719
- <div className="p-1">
720
- {availableAssignees.map((assignee) => (
721
- <DropdownMenuItem
722
- key={assignee.id}
723
- onClick={() => toggleAssignee(assignee)}
724
- className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
725
- >
726
- <Checkbox
727
- checked={filters.assignees.some(
728
- (a) => a.id === assignee.id
729
- )}
730
- className="pointer-events-none"
731
- />
732
- <Avatar className="h-6 w-6 border">
733
- {assignee.avatar_url && (
734
- <AvatarImage src={assignee.avatar_url} />
735
- )}
736
- <AvatarFallback className="font-medium text-[10px]">
737
- {getInitials(
738
- assignee.display_name || assignee.email || ''
739
- )}
740
- </AvatarFallback>
741
- </Avatar>
742
- <div className="flex flex-1 flex-col overflow-hidden">
743
- <span className="truncate font-medium text-sm">
744
- {assignee.display_name || assignee.email}
745
- </span>
746
- {assignee.display_name && assignee.email && (
747
- <span className="truncate text-muted-foreground text-xs">
748
- {assignee.email}
749
- </span>
750
- )}
751
- </div>
752
- </DropdownMenuItem>
753
- ))}
754
- </div>
718
+ </FilterSection>
719
+ )}
720
+
721
+ <FilterSection
722
+ icon={<ListFilter className="h-3.5 w-3.5" />}
723
+ title={t('ws-tasks.filter_source_scope')}
724
+ badge={
725
+ isSourceFilterActive ? (
726
+ <Badge variant="secondary">{sourceFilterCount}</Badge>
727
+ ) : null
728
+ }
729
+ >
730
+ <Combobox
731
+ mode="single"
732
+ options={sourceScopeOptions}
733
+ selected={sourceScope}
734
+ onChange={(value) => setSourceScope(value as TaskSourceScope)}
735
+ placeholder={t('ws-tasks.filter_source_scope')}
736
+ searchPlaceholder={t('common.search_tasks')}
737
+ className="[&_button]:h-9"
738
+ />
739
+ {sourceScope === 'external_specific' && (
740
+ <div className="mt-2 grid gap-2 sm:grid-cols-2">
741
+ <FilterPickerField
742
+ icon={<Building2 className="h-3.5 w-3.5" />}
743
+ label={t('ws-tasks.filter_workspaces')}
744
+ badge={
745
+ selectedSourceWorkspaceIds.length ? (
746
+ <Badge variant="secondary">
747
+ {selectedSourceWorkspaceIds.length}
748
+ </Badge>
749
+ ) : null
750
+ }
751
+ >
752
+ <Combobox
753
+ mode="multiple"
754
+ options={sourceWorkspaceOptions}
755
+ selected={selectedSourceWorkspaceIds}
756
+ onChange={(value) =>
757
+ setSourceWorkspaceIds(value as string[])
758
+ }
759
+ placeholder={t('ws-tasks.filter_workspaces')}
760
+ searchPlaceholder={t('common.search_tasks')}
761
+ emptyText={t('ws-tasks.filter_no_workspaces_available')}
762
+ className="[&_button]:h-9"
763
+ />
764
+ </FilterPickerField>
765
+
766
+ <FilterPickerField
767
+ icon={<LayoutDashboard className="h-3.5 w-3.5" />}
768
+ label={t('ws-tasks.filter_boards')}
769
+ badge={
770
+ selectedSourceBoardIds.length ? (
771
+ <Badge variant="secondary">
772
+ {selectedSourceBoardIds.length}
773
+ </Badge>
774
+ ) : null
775
+ }
776
+ >
777
+ <Combobox
778
+ mode="multiple"
779
+ options={sourceBoardOptions}
780
+ selected={selectedSourceBoardIds}
781
+ onChange={(value) =>
782
+ setSourceBoardIds(value as string[])
783
+ }
784
+ placeholder={
785
+ selectedSourceWorkspaceIds.length
786
+ ? t('ws-tasks.filter_boards')
787
+ : t('ws-tasks.filter_select_source_prompt')
788
+ }
789
+ searchPlaceholder={t('common.search_boards')}
790
+ emptyText={
791
+ sourceBoardsLoading
792
+ ? t('common.loading')
793
+ : t('ws-tasks.filter_no_boards_for_workspaces')
794
+ }
795
+ disabled={selectedSourceWorkspaceIds.length === 0}
796
+ className="[&_button]:h-9"
797
+ />
798
+ </FilterPickerField>
755
799
  </div>
756
800
  )}
757
- </DropdownMenuSubContent>
758
- </DropdownMenuSub>
759
-
760
- {/* Labels */}
761
- <DropdownMenuSub>
762
- <DropdownMenuSubTrigger className="gap-2 py-2.5">
763
- <Tag className="h-4 w-4 text-muted-foreground" />
764
- <span className="flex-1">{t('common.labels')}</span>
765
- {filters.labels.length > 0 && (
766
- <Badge
767
- variant="secondary"
768
- className="h-4 min-w-5 justify-center px-1 text-[10px]"
801
+ </FilterSection>
802
+
803
+ <FilterSection
804
+ icon={<Users className="h-3.5 w-3.5" />}
805
+ title={t('common.people')}
806
+ badge={
807
+ peopleFilterCount ? (
808
+ <Badge variant="secondary">{peopleFilterCount}</Badge>
809
+ ) : null
810
+ }
811
+ >
812
+ <Combobox
813
+ mode="multiple"
814
+ options={assigneeOptions}
815
+ selected={filters.assignees.map((assignee) => assignee.id)}
816
+ onChange={(value) => setAssigneeIds(value as string[])}
817
+ placeholder={t('common.assignees')}
818
+ searchPlaceholder={t('common.search_members')}
819
+ emptyText={t('common.no_members_found')}
820
+ className="[&_button]:h-9"
821
+ />
822
+ </FilterSection>
823
+
824
+ <FilterSection
825
+ icon={<Tag className="h-3.5 w-3.5" />}
826
+ title={t('common.details')}
827
+ badge={
828
+ detailFilterCount ? (
829
+ <Badge variant="secondary">{detailFilterCount}</Badge>
830
+ ) : null
831
+ }
832
+ >
833
+ <div className="grid gap-2 sm:grid-cols-2">
834
+ <FilterPickerField
835
+ icon={<Tag className="h-3.5 w-3.5" />}
836
+ label={t('common.labels')}
837
+ badge={
838
+ filters.labels.length ? (
839
+ <Badge variant="secondary">
840
+ {filters.labels.length}
841
+ </Badge>
842
+ ) : null
843
+ }
769
844
  >
770
- {filters.labels.length}
771
- </Badge>
772
- )}
773
- </DropdownMenuSubTrigger>
774
- <DropdownMenuSubContent className="w-60 p-0">
775
- {availableLabels.length === 0 ? (
776
- <div className="p-4 text-center text-muted-foreground text-sm">
777
- {t('common.no_labels_found')}
778
- </div>
779
- ) : (
780
- <div className="max-h-60 overflow-y-auto">
781
- <div className="p-1">
782
- {availableLabels.map((label) => (
783
- <DropdownMenuItem
784
- key={label.id}
785
- onClick={() => toggleLabel(label)}
786
- className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-2"
787
- >
788
- <Checkbox
789
- checked={filters.labels.some(
790
- (l) => l.id === label.id
791
- )}
792
- className="pointer-events-none"
793
- />
794
- <Badge
795
- style={getColorStyles(label.color)}
796
- className="border-0 font-medium text-xs"
797
- >
798
- {label.name}
845
+ <Combobox
846
+ mode="multiple"
847
+ options={labelOptions}
848
+ selected={filters.labels.map((label) => label.id)}
849
+ onChange={(value) => setLabelIds(value as string[])}
850
+ placeholder={t('common.labels')}
851
+ searchPlaceholder={t('common.search_labels')}
852
+ emptyText={t('common.no_labels_found')}
853
+ className="[&_button]:h-9"
854
+ />
855
+ </FilterPickerField>
856
+
857
+ {availableProjects.length > 0 && (
858
+ <FilterPickerField
859
+ icon={<Hash className="h-3.5 w-3.5" />}
860
+ label={t('common.projects')}
861
+ badge={
862
+ filters.projects.length ? (
863
+ <Badge variant="secondary">
864
+ {filters.projects.length}
799
865
  </Badge>
800
- </DropdownMenuItem>
801
- ))}
802
- </div>
803
- </div>
804
- )}
805
- </DropdownMenuSubContent>
806
- </DropdownMenuSub>
807
-
808
- {/* Projects */}
809
- {availableProjects.length > 0 && (
810
- <DropdownMenuSub>
811
- <DropdownMenuSubTrigger className="gap-2 py-2.5">
812
- <Hash className="h-4 w-4 text-muted-foreground" />
813
- <span className="flex-1">{t('common.projects')}</span>
814
- {filters.projects.length > 0 && (
815
- <Badge
816
- variant="secondary"
817
- className="h-4 min-w-5 justify-center px-1 text-[10px]"
866
+ ) : null
867
+ }
818
868
  >
819
- {filters.projects.length}
820
- </Badge>
869
+ <Combobox
870
+ mode="multiple"
871
+ options={projectOptions}
872
+ selected={filters.projects.map((project) => project.id)}
873
+ onChange={(value) => setProjectIds(value as string[])}
874
+ placeholder={t('common.projects')}
875
+ searchPlaceholder={t('common.search_projects')}
876
+ emptyText={t('common.empty')}
877
+ className="[&_button]:h-9"
878
+ />
879
+ </FilterPickerField>
821
880
  )}
822
- </DropdownMenuSubTrigger>
823
- <DropdownMenuSubContent className="w-60 p-0">
824
- <div className="max-h-60 overflow-y-auto">
825
- <div className="p-1">
826
- {availableProjects.map((project) => (
827
- <DropdownMenuItem
828
- key={project.id}
829
- onClick={() => toggleProject(project)}
830
- className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-2"
831
- >
832
- <Checkbox
833
- checked={filters.projects.some(
834
- (p) => p.id === project.id
835
- )}
836
- className="pointer-events-none"
837
- />
838
- <span className="font-medium text-sm">
839
- {project.name}
840
- </span>
841
- </DropdownMenuItem>
842
- ))}
843
- </div>
844
- </div>
845
- </DropdownMenuSubContent>
846
- </DropdownMenuSub>
847
- )}
848
-
849
- {/* Priority */}
850
- <DropdownMenuSub>
851
- <DropdownMenuSubTrigger className="gap-2 py-2.5">
852
- <Flag className="h-4 w-4 text-muted-foreground" />
853
- <span className="flex-1">{t('common.priority')}</span>
854
- {filters.priorities.length > 0 && (
855
- <Badge
856
- variant="secondary"
857
- className="h-4 min-w-5 justify-center px-1 text-[10px]"
881
+
882
+ <FilterPickerField
883
+ icon={<Flag className="h-3.5 w-3.5" />}
884
+ label={t('common.priority')}
885
+ badge={
886
+ filters.priorities.length ? (
887
+ <Badge variant="secondary">
888
+ {filters.priorities.length}
889
+ </Badge>
890
+ ) : null
891
+ }
858
892
  >
859
- {filters.priorities.length}
860
- </Badge>
861
- )}
862
- </DropdownMenuSubTrigger>
863
- <DropdownMenuSubContent className="w-50 p-0">
864
- <div className="p-1">
865
- {PRIORITIES.map((priority) => (
866
- <DropdownMenuItem
867
- key={priority.value}
868
- onClick={() => togglePriority(priority.value)}
869
- className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2"
893
+ <Combobox
894
+ mode="multiple"
895
+ options={priorityOptions}
896
+ selected={filters.priorities}
897
+ onChange={(value) => setPriorityValues(value as string[])}
898
+ placeholder={t('common.priority')}
899
+ searchPlaceholder={t('common.search_tasks')}
900
+ className="[&_button]:h-9"
901
+ />
902
+ </FilterPickerField>
903
+ </div>
904
+ </FilterSection>
905
+
906
+ <FilterSection
907
+ icon={<CalendarIcon className="h-3.5 w-3.5" />}
908
+ title={t('common.due_date')}
909
+ badge={
910
+ filters.dueDateRange ? (
911
+ <Badge variant="secondary">1</Badge>
912
+ ) : null
913
+ }
914
+ >
915
+ <div className="space-y-2">
916
+ <div className="flex min-w-0 items-center gap-2 rounded-md border bg-muted/20 px-2 py-1.5 text-muted-foreground text-xs">
917
+ <CalendarIcon className="h-3.5 w-3.5 shrink-0" />
918
+ <span className="min-w-0 truncate">{dueDateSummary}</span>
919
+ </div>
920
+ <div className="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.25rem]">
921
+ <Input
922
+ aria-label={t('common.from')}
923
+ className="h-9 min-w-0"
924
+ type="date"
925
+ value={formatDateInputValue(filters.dueDateRange?.from)}
926
+ onChange={(event) => {
927
+ const nextFrom = parseDateInputValue(
928
+ event.target.value
929
+ );
930
+ const nextTo = filters.dueDateRange?.to;
931
+
932
+ onFiltersChange({
933
+ ...filters,
934
+ dueDateRange:
935
+ nextFrom || nextTo
936
+ ? { from: nextFrom, to: nextTo }
937
+ : null,
938
+ });
939
+ }}
940
+ />
941
+ <Input
942
+ aria-label={t('common.to')}
943
+ className="h-9 min-w-0"
944
+ type="date"
945
+ value={formatDateInputValue(filters.dueDateRange?.to)}
946
+ onChange={(event) => {
947
+ const nextFrom = filters.dueDateRange?.from;
948
+ const nextTo = parseDateInputValue(event.target.value);
949
+
950
+ onFiltersChange({
951
+ ...filters,
952
+ dueDateRange:
953
+ nextFrom || nextTo
954
+ ? { from: nextFrom, to: nextTo }
955
+ : null,
956
+ });
957
+ }}
958
+ />
959
+ <Button
960
+ type="button"
961
+ variant="outline"
962
+ size="icon"
963
+ className="h-9 w-full sm:w-9"
964
+ aria-label={t('common.clear')}
965
+ onClick={() =>
966
+ onFiltersChange({
967
+ ...filters,
968
+ dueDateRange: null,
969
+ })
970
+ }
971
+ disabled={!filters.dueDateRange}
870
972
  >
871
- <Checkbox
872
- checked={filters.priorities.includes(priority.value)}
873
- className="pointer-events-none"
874
- />
875
- <Flag className={cn(priority.color, 'h-4 w-4')} />
876
- <span className="font-medium text-sm">
877
- {t(priority.labelKey as any)}
878
- </span>
879
- </DropdownMenuItem>
880
- ))}
973
+ <X className="h-4 w-4" />
974
+ </Button>
975
+ </div>
881
976
  </div>
882
- </DropdownMenuSubContent>
883
- </DropdownMenuSub>
884
-
885
- {/* Due Date */}
886
- <DropdownMenuSub>
887
- <DropdownMenuSubTrigger className="gap-2 py-2.5">
888
- <CalendarIcon className="h-4 w-4 text-muted-foreground" />
889
- <span className="flex-1">{t('common.due_date')}</span>
890
- {filters.dueDateRange && (
891
- <Check className="h-3.5 w-3.5 text-primary" />
892
- )}
893
- </DropdownMenuSubTrigger>
894
- <DropdownMenuSubContent className="w-auto p-0">
895
- <Calendar
896
- mode="range"
897
- selected={
898
- filters.dueDateRange
899
- ? {
900
- from: filters.dueDateRange.from,
901
- to: filters.dueDateRange.to,
902
- }
903
- : undefined
904
- }
905
- onSelect={(range) =>
906
- onFiltersChange({
907
- ...filters,
908
- dueDateRange: range
909
- ? { from: range.from, to: range.to }
910
- : null,
911
- })
912
- }
913
- numberOfMonths={1}
914
- className="rounded-md border-0"
915
- />
916
- </DropdownMenuSubContent>
917
- </DropdownMenuSub>
918
-
919
- {/* Clear All */}
920
- {hasFilters && (
921
- <>
922
- <DropdownMenuSeparator />
923
- <DropdownMenuItem
977
+ </FilterSection>
978
+
979
+ {hasFilters && (
980
+ <Button
981
+ type="button"
982
+ variant="ghost"
924
983
  onClick={clearAllFilters}
925
- className="gap-2 text-dynamic-red/80 focus:text-dynamic-red"
984
+ className="h-8 w-full justify-start gap-2 text-dynamic-red/80 hover:text-dynamic-red"
926
985
  >
927
986
  <X className="h-4 w-4" />
928
987
  {t('common.clear_all_filters')}
929
- </DropdownMenuItem>
930
- </>
931
- )}
988
+ </Button>
989
+ )}
990
+ </div>
932
991
  </ScrollArea>
933
- </DropdownMenuContent>
934
- </DropdownMenu>
935
-
936
- {/* Active filter chips */}
937
- {/* {filters.labels.map((label) => (
938
- <Badge
939
- key={label.id}
940
- style={getColorStyles(label.color)}
941
- className="h-5 cursor-pointer border-0 px-1.5 text-[10px] hover:opacity-80 sm:h-6 sm:px-2 sm:text-xs"
942
- onClick={() => toggleLabel(label)}
943
- >
944
- {label.name}
945
- <X className="ml-0.5 h-2.5 w-2.5 sm:ml-1 sm:h-3 sm:w-3" />
946
- </Badge>
947
- ))}
948
- {filters.assignees.map((assignee) => (
949
- <Badge
950
- key={assignee.id}
951
- variant="secondary"
952
- className="h-5 cursor-pointer px-1.5 text-[10px] hover:opacity-80 sm:h-6 sm:px-2 sm:text-xs"
953
- onClick={() => toggleAssignee(assignee)}
954
- >
955
- {assignee.display_name || assignee.email}
956
- <X className="ml-0.5 h-2.5 w-2.5 sm:ml-1 sm:h-3 sm:w-3" />
957
- </Badge>
958
- ))}
959
- {filters.projects.map((project) => (
960
- <Badge
961
- key={project.id}
962
- variant="outline"
963
- className="h-5 cursor-pointer px-1.5 text-[10px] hover:opacity-80 sm:h-6 sm:px-2 sm:text-xs"
964
- onClick={() => toggleProject(project)}
965
- >
966
- {project.name}
967
- <X className="ml-0.5 h-2.5 w-2.5 sm:ml-1 sm:h-3 sm:w-3" />
968
- </Badge>
969
- ))}
970
- {filters.priorities.map((priority) => {
971
- const priorityConfig = PRIORITIES.find((p) => p.value === priority);
972
- return (
973
- <Badge
974
- key={priority}
975
- variant="outline"
976
- className="h-5 cursor-pointer px-1.5 text-[10px] hover:opacity-80 sm:h-6 sm:px-2 sm:text-xs"
977
- onClick={() => togglePriority(priority)}
978
- >
979
- {priorityConfig?.label}
980
- <X className="ml-0.5 h-2.5 w-2.5 sm:ml-1 sm:h-3 sm:w-3" />
981
- </Badge>
982
- );
983
- })} */}
992
+ </PopoverContent>
993
+ </Popover>
984
994
  </div>
985
995
  );
986
996
  }