@tuturuuu/ui 0.13.0 → 0.16.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 (96) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/biome.json +1 -1
  3. package/package.json +62 -62
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
  5. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
  6. package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
  7. package/src/components/ui/custom/notification-popover-client.tsx +5 -1
  8. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
  9. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
  10. package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
  11. package/src/components/ui/custom/settings/task-settings.tsx +55 -0
  12. package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
  13. package/src/components/ui/custom/sidebar-context.tsx +58 -3
  14. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
  15. package/src/components/ui/custom/statistic-card.tsx +104 -0
  16. package/src/components/ui/custom/structure.test.tsx +29 -0
  17. package/src/components/ui/custom/structure.tsx +103 -88
  18. package/src/components/ui/finance/invoices/civil-month.ts +224 -0
  19. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
  20. package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
  21. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  22. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
  23. package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
  24. package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
  25. package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
  26. package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
  27. package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
  28. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
  29. package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
  30. package/src/components/ui/finance/invoices/utils.test.ts +84 -0
  31. package/src/components/ui/finance/invoices/utils.ts +56 -134
  32. package/src/components/ui/resizable.test.ts +15 -0
  33. package/src/components/ui/resizable.tsx +28 -6
  34. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
  35. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -0
  36. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
  37. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
  38. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +48 -0
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
  40. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
  42. package/src/components/ui/tu-do/boards/boardId/menus/__tests__/task-menus.test.tsx +41 -3
  43. package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
  44. package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
  45. package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
  46. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
  47. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
  48. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
  49. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
  50. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
  51. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
  52. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
  53. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
  54. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
  55. package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
  56. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
  57. package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
  58. package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
  59. package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
  60. package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
  61. package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
  62. package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
  63. package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
  64. package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
  65. package/src/components/ui/tu-do/projects/types.ts +4 -1
  66. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
  67. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
  68. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
  69. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
  70. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
  71. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
  72. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
  73. package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
  74. package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
  75. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  76. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
  82. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
  83. package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
  84. package/src/components/ui/tu-do/shared/task-row-actions-menu.test.tsx +138 -0
  85. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
  86. package/src/components/ui/tu-do/templates/client.tsx +97 -78
  87. package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
  88. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
  89. package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
  90. package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
  91. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
  92. package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
  93. package/src/hooks/task-actions-personal-external.ts +12 -6
  94. package/src/hooks/use-notifications.ts +14 -2
  95. package/src/hooks/use-user-config.ts +10 -2
  96. package/vitest.config.ts +4 -0
@@ -1,4 +1,30 @@
1
1
  import type { Database } from '@tuturuuu/types';
2
+ import {
3
+ formatMonthLabel,
4
+ formatMonthValue,
5
+ getCoverageMonths,
6
+ getCurrentMonthStartDate,
7
+ getMonthStartDate,
8
+ parseLocalCalendarDate,
9
+ } from './civil-month';
10
+
11
+ export {
12
+ addMonthsToMonthValue,
13
+ formatCoverageRangeLabel,
14
+ formatMonthLabel,
15
+ formatMonthValue,
16
+ getCoverageEndMonthValue,
17
+ getCoverageMonths,
18
+ getCoverageValidUntilMonthValue,
19
+ getCurrentBillingDate,
20
+ getCurrentMonthStartDate,
21
+ getCurrentMonthValue,
22
+ getMonthStartDate,
23
+ MAX_PREPAID_MONTH_COUNT,
24
+ PREPAID_MONTH_OPTION_HORIZON,
25
+ parseLocalCalendarDate,
26
+ resolveBillingTimezone,
27
+ } from './civil-month';
2
28
 
3
29
  type AttendanceRecord = {
4
30
  status: string;
@@ -67,134 +93,25 @@ export const getLinkedFinanceCategorySelection = (
67
93
  };
68
94
  };
69
95
 
70
- const MONTH_VALUE_PATTERN = /^(\d{4})-(\d{2})$/;
71
- const DATE_VALUE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
72
- export const MAX_PREPAID_MONTH_COUNT = 12;
73
- export const PREPAID_MONTH_OPTION_HORIZON = 12;
74
-
75
- export const parseLocalCalendarDate = (
76
- value: string | Date | null | undefined
77
- ): Date => {
78
- if (!value) {
79
- return new Date(Number.NaN);
80
- }
81
-
82
- if (value instanceof Date) {
83
- return new Date(value.getTime());
84
- }
85
-
86
- const monthMatch = MONTH_VALUE_PATTERN.exec(value);
87
- if (monthMatch) {
88
- const [, year, month] = monthMatch;
89
- return new Date(Number(year), Number(month) - 1, 1);
90
- }
91
-
92
- const dateMatch = DATE_VALUE_PATTERN.exec(value);
93
- if (dateMatch) {
94
- const [, year, month, day] = dateMatch;
95
- return new Date(Number(year), Number(month) - 1, Number(day));
96
- }
97
-
98
- return new Date(value);
99
- };
100
-
101
- export const getMonthStartDate = (
102
- value: string | Date | null | undefined
103
- ): Date => {
104
- const date = parseLocalCalendarDate(value);
105
- if (Number.isNaN(date.getTime())) {
106
- return date;
107
- }
108
-
109
- return new Date(date.getFullYear(), date.getMonth(), 1);
110
- };
111
-
112
- export const formatMonthValue = (date: Date): string =>
113
- `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
114
-
115
- export const formatMonthLabel = (month: string, locale: string): string => {
116
- const date = getMonthStartDate(month);
117
- if (Number.isNaN(date.getTime())) {
118
- return month;
119
- }
120
-
121
- return date.toLocaleDateString(locale, {
122
- year: 'numeric',
123
- month: 'long',
124
- });
125
- };
126
-
127
- export const addMonthsToMonthValue = (
128
- month: string,
129
- monthOffset: number
130
- ): string => {
131
- const date = getMonthStartDate(month);
132
- if (Number.isNaN(date.getTime())) return month;
133
-
134
- date.setMonth(date.getMonth() + monthOffset);
135
- return formatMonthValue(date);
136
- };
137
-
138
- export const normalizePrepaidMonthCount = (
139
- value: number | null | undefined
140
- ): number => {
141
- if (!Number.isFinite(value) || !value) return 1;
142
-
143
- return Math.min(MAX_PREPAID_MONTH_COUNT, Math.max(1, Math.trunc(value)));
144
- };
145
-
146
- export const getCoverageMonths = (
147
- selectedMonth: string,
148
- prepaidMonthCount = 1
149
- ): string[] => {
150
- const monthCount = normalizePrepaidMonthCount(prepaidMonthCount);
151
- const startMonth = getMonthStartDate(selectedMonth);
152
- if (Number.isNaN(startMonth.getTime())) return [];
153
-
154
- return Array.from({ length: monthCount }, (_, index) => {
155
- const date = new Date(startMonth);
156
- date.setMonth(date.getMonth() + index);
157
- return formatMonthValue(date);
158
- });
159
- };
160
-
161
- export const getCoverageEndMonthValue = (
162
- selectedMonth: string,
163
- prepaidMonthCount = 1
164
- ): string => {
165
- const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
166
- return coverageMonths[coverageMonths.length - 1] ?? selectedMonth;
167
- };
168
-
169
- export const getCoverageValidUntilMonthValue = (
170
- selectedMonth: string,
171
- prepaidMonthCount = 1
172
- ): string => addMonthsToMonthValue(selectedMonth, prepaidMonthCount);
173
-
174
- export const formatCoverageRangeLabel = ({
175
- locale,
176
- prepaidMonthCount,
177
- selectedMonth,
96
+ export const resolveSubscriptionInvoiceCategoryId = ({
97
+ defaultCategoryId,
98
+ items,
178
99
  }: {
179
- locale: string;
180
- prepaidMonthCount?: number | null;
181
- selectedMonth: string;
182
- }): string => {
183
- const coverageMonths = getCoverageMonths(
184
- selectedMonth,
185
- prepaidMonthCount ?? 1
186
- );
187
- const firstMonth = coverageMonths[0];
188
- const lastMonth = coverageMonths[coverageMonths.length - 1];
100
+ defaultCategoryId?: string | null;
101
+ items: FinanceCategoryLinkedItem[];
102
+ }) => {
103
+ const linkedFinanceCategorySelection =
104
+ getLinkedFinanceCategorySelection(items);
105
+
106
+ if (linkedFinanceCategorySelection.hasSingleCategory) {
107
+ return linkedFinanceCategorySelection.categoryId ?? '';
108
+ }
189
109
 
190
- if (!firstMonth || !lastMonth || firstMonth === lastMonth) {
191
- return formatMonthLabel(selectedMonth, locale);
110
+ if (linkedFinanceCategorySelection.hasMixedCategories) {
111
+ return '';
192
112
  }
193
113
 
194
- return `${formatMonthLabel(firstMonth, locale)} - ${formatMonthLabel(
195
- lastMonth,
196
- locale
197
- )}`;
114
+ return defaultCategoryId ?? '';
198
115
  };
199
116
 
200
117
  const getComparableTimestamp = (value: string | null | undefined): number => {
@@ -532,7 +449,6 @@ export const getSessionsUntilMonth = (
532
449
  }
533
450
  };
534
451
 
535
- /** Date range (earliest start, latest end) for selected groups. */
536
452
  export type GroupsDateRange = {
537
453
  earliestStart: Date | null;
538
454
  latestEnd: Date | null;
@@ -583,17 +499,20 @@ export const getAvailableMonths = (
583
499
  latestInvoices: SubscriptionCoverageInvoice[],
584
500
  locale: string,
585
501
  selectedMonthFallback: string | null = null,
586
- futureMonthHorizon = 0
502
+ futureMonthHorizon = 0,
503
+ options: {
504
+ now?: Date;
505
+ workspaceTimezone?: string | null;
506
+ } = {}
587
507
  ): AvailableMonthOption[] => {
588
508
  if (groupIds.length === 0) return [];
589
509
  const { earliestStart, latestEnd } = getGroupsDateRange(userGroups, groupIds);
590
510
  if (!earliestStart) return [];
591
511
 
592
- const currentMonthStart = (() => {
593
- const date = new Date();
594
- date.setDate(1);
595
- return date;
596
- })();
512
+ const currentMonthStart = getCurrentMonthStartDate(
513
+ options.workspaceTimezone,
514
+ options.now
515
+ );
597
516
  const futureHorizonEnd = new Date(currentMonthStart);
598
517
  futureHorizonEnd.setMonth(
599
518
  futureHorizonEnd.getMonth() + Math.max(0, futureMonthHorizon)
@@ -648,6 +567,7 @@ export const getBillableQuantityForGroupRange = ({
648
567
  now = new Date(),
649
568
  prepaidMonthCount = 1,
650
569
  selectedMonth,
570
+ workspaceTimezone,
651
571
  useAttendanceBased,
652
572
  userAttendance,
653
573
  userGroups,
@@ -657,11 +577,12 @@ export const getBillableQuantityForGroupRange = ({
657
577
  now?: Date;
658
578
  prepaidMonthCount?: number;
659
579
  selectedMonth: string;
580
+ workspaceTimezone?: string | null;
660
581
  useAttendanceBased: boolean;
661
582
  userAttendance: AttendanceRecord[];
662
583
  userGroups: UserGroup[];
663
584
  }): number => {
664
- const currentMonthStart = getMonthStartDate(now);
585
+ const currentMonthStart = getCurrentMonthStartDate(workspaceTimezone, now);
665
586
  const coverageMonths = getCoverageMonths(selectedMonth, prepaidMonthCount);
666
587
 
667
588
  return coverageMonths.reduce((total, month) => {
@@ -708,6 +629,7 @@ export const getBillableQuantityMapForGroupsRange = ({
708
629
  now,
709
630
  prepaidMonthCount = 1,
710
631
  selectedMonth,
632
+ workspaceTimezone,
711
633
  useAttendanceBased,
712
634
  userAttendance,
713
635
  userGroups,
@@ -717,6 +639,7 @@ export const getBillableQuantityMapForGroupsRange = ({
717
639
  now?: Date;
718
640
  prepaidMonthCount?: number;
719
641
  selectedMonth: string;
642
+ workspaceTimezone?: string | null;
720
643
  useAttendanceBased: boolean;
721
644
  userAttendance: AttendanceRecord[];
722
645
  userGroups: UserGroup[];
@@ -730,6 +653,7 @@ export const getBillableQuantityMapForGroupsRange = ({
730
653
  now,
731
654
  prepaidMonthCount,
732
655
  selectedMonth,
656
+ workspaceTimezone,
733
657
  useAttendanceBased,
734
658
  userAttendance,
735
659
  userGroups,
@@ -737,11 +661,9 @@ export const getBillableQuantityMapForGroupsRange = ({
737
661
  ])
738
662
  );
739
663
 
740
- /** Days before valid_until to consider "expiring soon" */
741
664
  const EXPIRING_SOON_DAYS = 14;
742
665
 
743
666
  export type GroupPaymentStatus = 'active' | 'expiringSoon' | 'expired';
744
-
745
667
  export function getGroupPaymentStatus(
746
668
  group: WorkspaceUserGroup | null,
747
669
  latestInvoice:
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { normalizeLegacyPanelSize } from './resizable';
3
+
4
+ describe('normalizeLegacyPanelSize', () => {
5
+ it('preserves the shared wrapper numeric percentage contract', () => {
6
+ expect(normalizeLegacyPanelSize(38)).toBe('38%');
7
+ expect(normalizeLegacyPanelSize(0)).toBe('0%');
8
+ });
9
+
10
+ it('leaves explicitly unitized sizes unchanged', () => {
11
+ expect(normalizeLegacyPanelSize('20rem')).toBe('20rem');
12
+ expect(normalizeLegacyPanelSize('38%')).toBe('38%');
13
+ expect(normalizeLegacyPanelSize(undefined)).toBeUndefined();
14
+ });
15
+ });
@@ -16,6 +16,13 @@ interface ResizablePanelGroupProps
16
16
  onLayout?: (sizes: number[]) => void;
17
17
  }
18
18
 
19
+ export function normalizeLegacyPanelSize(size: number | string | undefined) {
20
+ // react-resizable-panels v3 and the shadcn wrapper treated numeric values as
21
+ // percentages. v4 treats them as pixels, so preserve the shared wrapper's
22
+ // established contract while still allowing explicit CSS units.
23
+ return typeof size === 'number' ? `${size}%` : size;
24
+ }
25
+
19
26
  function ResizablePanelGroup({
20
27
  className,
21
28
  direction,
@@ -26,11 +33,11 @@ function ResizablePanelGroup({
26
33
  <Group
27
34
  data-slot="resizable-panel-group"
28
35
  orientation={direction}
29
- onLayoutChange={
36
+ onLayoutChanged={
30
37
  onLayout
31
38
  ? (layout) => {
32
- // Convert Layout (Record<string, number>) to number[] for backward compatibility
33
- // Layout values are already percentages (0-100)
39
+ // Layout values are flex percentages (0-100). Preserve the
40
+ // wrapper's array callback for existing consumers.
34
41
  const sizes = Object.values(layout);
35
42
  onLayout(sizes);
36
43
  }
@@ -45,8 +52,23 @@ function ResizablePanelGroup({
45
52
  );
46
53
  }
47
54
 
48
- function ResizablePanel({ ...props }: React.ComponentProps<typeof Panel>) {
49
- return <Panel data-slot="resizable-panel" {...props} />;
55
+ function ResizablePanel({
56
+ collapsedSize,
57
+ defaultSize,
58
+ maxSize,
59
+ minSize,
60
+ ...props
61
+ }: React.ComponentProps<typeof Panel>) {
62
+ return (
63
+ <Panel
64
+ collapsedSize={normalizeLegacyPanelSize(collapsedSize)}
65
+ data-slot="resizable-panel"
66
+ defaultSize={normalizeLegacyPanelSize(defaultSize)}
67
+ maxSize={normalizeLegacyPanelSize(maxSize)}
68
+ minSize={normalizeLegacyPanelSize(minSize)}
69
+ {...props}
70
+ />
71
+ );
50
72
  }
51
73
 
52
74
  function ResizableHandle({
@@ -60,7 +82,7 @@ function ResizableHandle({
60
82
  <Separator
61
83
  data-slot="resizable-handle"
62
84
  className={cn(
63
- 'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
85
+ 'relative flex w-px cursor-col-resize items-center justify-center bg-border transition-colors after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-primary/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-3 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
64
86
  className
65
87
  )}
66
88
  {...props}
@@ -182,6 +182,7 @@ interface BoardColumnProps {
182
182
  specialPinned?: boolean;
183
183
  specialStickyOffset?: string;
184
184
  onSpecialPinnedChange?: (pinned: boolean) => void;
185
+ onHoverTaskList?: (listId: string) => void;
185
186
  readOnly?: boolean;
186
187
  }
187
188
 
@@ -214,6 +215,7 @@ export function BoardColumn({
214
215
  specialPinned = false,
215
216
  specialStickyOffset,
216
217
  onSpecialPinnedChange,
218
+ onHoverTaskList,
217
219
  readOnly = false,
218
220
  }: BoardColumnProps) {
219
221
  const t = useTranslations('common');
@@ -530,6 +532,7 @@ export function BoardColumn({
530
532
  data-kanban-pinned-special={specialStickyOffset ? 'true' : undefined}
531
533
  data-kanban-column-id={column.id}
532
534
  data-kanban-real-column={isExternalStaging ? undefined : 'true'}
535
+ onPointerEnter={() => onHoverTaskList?.(column.id)}
533
536
  className={cn(
534
537
  'group flex h-full w-14 shrink-0 snap-start flex-col items-center rounded-xl border border-dashed transition-all duration-200',
535
538
  'touch-none select-none overflow-hidden hover:shadow-md',
@@ -583,6 +586,7 @@ export function BoardColumn({
583
586
  data-kanban-pinned-special={specialStickyOffset ? 'true' : undefined}
584
587
  data-kanban-column-id={column.id}
585
588
  data-kanban-real-column={isExternalStaging ? undefined : 'true'}
589
+ onPointerEnter={() => onHoverTaskList?.(column.id)}
586
590
  className={cn(
587
591
  'group flex h-full w-[var(--kanban-column-width)] shrink-0 snap-start flex-col rounded-xl transition-all duration-200 last:snap-end',
588
592
  'touch-none select-none',
@@ -187,4 +187,66 @@ describe('bulk move mutations', () => {
187
187
  intensity: 1.2,
188
188
  });
189
189
  });
190
+
191
+ it('bulk-moves selected external tasks to done through terminal personal placement', async () => {
192
+ const doneList = {
193
+ ...targetList,
194
+ id: 'done-list',
195
+ name: 'Done',
196
+ status: 'done',
197
+ } as unknown as TaskList;
198
+
199
+ queryClient.setQueryData(['tasks', 'board-1'], [localTask, externalTask]);
200
+ mockUpsertCurrentUserTaskPersonalPlacement.mockResolvedValueOnce({
201
+ task: {
202
+ ...externalTask,
203
+ list_id: 'done-list',
204
+ personal_list_id: 'done-list',
205
+ source_list_id: 'source-done-list',
206
+ source_list_status: 'done',
207
+ },
208
+ });
209
+
210
+ const { result } = renderHook(
211
+ () =>
212
+ useBulkOperations({
213
+ queryClient,
214
+ wsId: 'personal-ws',
215
+ boardId: 'board-1',
216
+ selectedTasks: new Set(['local-task', 'external-task']),
217
+ columns: [sourceList, doneList],
218
+ workspaceLabels: [],
219
+ workspaceProjects: [],
220
+ workspaceMembers: [],
221
+ setBulkWorking: vi.fn(),
222
+ clearSelection: vi.fn(),
223
+ setBulkDeleteOpen: vi.fn(),
224
+ }),
225
+ { wrapper }
226
+ );
227
+
228
+ await act(async () => {
229
+ await result.current.bulkMoveToList('done-list', 'Done');
230
+ });
231
+
232
+ expect(mockBulkWorkspaceTasks).toHaveBeenCalledWith(
233
+ 'personal-ws',
234
+ {
235
+ taskIds: ['local-task'],
236
+ operation: {
237
+ type: 'move_to_list',
238
+ listId: 'done-list',
239
+ },
240
+ },
241
+ expect.anything()
242
+ );
243
+ expect(mockUpsertCurrentUserTaskPersonalPlacement).toHaveBeenCalledWith(
244
+ 'external-task',
245
+ expect.objectContaining({
246
+ personal_board_id: 'board-1',
247
+ personal_list_id: 'done-list',
248
+ terminal_status: 'done',
249
+ })
250
+ );
251
+ });
190
252
  });
@@ -0,0 +1,118 @@
1
+ import type { DragMoveEvent } from '@dnd-kit/core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import {
4
+ getKanbanDragAutoScrollPointerX,
5
+ getKanbanEdgeAutoScrollAmount,
6
+ } from './auto-scroll';
7
+
8
+ const rect = {
9
+ left: 100,
10
+ right: 500,
11
+ };
12
+
13
+ function createDragMoveEvent({
14
+ deltaX = 0,
15
+ initialLeft = 200,
16
+ pointerStartX,
17
+ translatedLeft = initialLeft,
18
+ width = 80,
19
+ }: {
20
+ deltaX?: number;
21
+ initialLeft?: number;
22
+ pointerStartX?: number;
23
+ translatedLeft?: number | null;
24
+ width?: number;
25
+ }) {
26
+ return {
27
+ active: {
28
+ rect: {
29
+ current: {
30
+ initial: {
31
+ left: initialLeft,
32
+ width,
33
+ },
34
+ translated:
35
+ translatedLeft === null
36
+ ? null
37
+ : {
38
+ left: translatedLeft,
39
+ width,
40
+ },
41
+ },
42
+ },
43
+ },
44
+ activatorEvent:
45
+ pointerStartX === undefined
46
+ ? ({ type: 'keydown' } as Event)
47
+ : ({ clientX: pointerStartX } as unknown as Event),
48
+ delta: {
49
+ x: deltaX,
50
+ y: 0,
51
+ },
52
+ } as unknown as DragMoveEvent;
53
+ }
54
+
55
+ describe('getKanbanEdgeAutoScrollAmount', () => {
56
+ it('scrolls left when the drag center is near the left edge', () => {
57
+ expect(
58
+ getKanbanEdgeAutoScrollAmount(125, rect, {
59
+ threshold: 100,
60
+ speed: 10,
61
+ maxSpeed: 30,
62
+ })
63
+ ).toBeLessThan(0);
64
+ });
65
+
66
+ it('scrolls right when the drag center is near the right edge', () => {
67
+ expect(
68
+ getKanbanEdgeAutoScrollAmount(475, rect, {
69
+ threshold: 100,
70
+ speed: 10,
71
+ maxSpeed: 30,
72
+ })
73
+ ).toBeGreaterThan(0);
74
+ });
75
+
76
+ it('does nothing away from horizontal edges or without a drag center', () => {
77
+ expect(getKanbanEdgeAutoScrollAmount(300, rect)).toBe(0);
78
+ expect(getKanbanEdgeAutoScrollAmount(null, rect)).toBe(0);
79
+ });
80
+
81
+ it('uses pointer position for edge auto-scroll instead of card center', () => {
82
+ const pointerX = getKanbanDragAutoScrollPointerX(
83
+ createDragMoveEvent({
84
+ deltaX: 215,
85
+ pointerStartX: 260,
86
+ translatedLeft: 190,
87
+ width: 80,
88
+ })
89
+ );
90
+
91
+ expect(pointerX).toBe(475);
92
+ expect(
93
+ getKanbanEdgeAutoScrollAmount(pointerX, rect, {
94
+ threshold: 100,
95
+ speed: 10,
96
+ maxSpeed: 30,
97
+ })
98
+ ).toBeGreaterThan(0);
99
+ expect(
100
+ getKanbanEdgeAutoScrollAmount(230, rect, {
101
+ threshold: 100,
102
+ speed: 10,
103
+ maxSpeed: 30,
104
+ })
105
+ ).toBe(0);
106
+ });
107
+
108
+ it('falls back to the active card center without pointer coordinates', () => {
109
+ expect(
110
+ getKanbanDragAutoScrollPointerX(
111
+ createDragMoveEvent({
112
+ translatedLeft: 370,
113
+ width: 100,
114
+ })
115
+ )
116
+ ).toBe(420);
117
+ });
118
+ });