@allbluecn/web-app 0.4.22 → 0.5.1

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 (99) hide show
  1. package/package.json +12 -8
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  4. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  5. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  6. package/src/components/notifications/settings-notifications.tsx +198 -0
  7. package/src/components/notifications/team-notifications.tsx +343 -0
  8. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  9. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  10. package/src/components/shared/wave-physics-loader.tsx +155 -0
  11. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  12. package/src/components/story/constants.ts +124 -51
  13. package/src/components/story/create-story-dialog.tsx +12 -6
  14. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  15. package/src/components/story/detail/activity-timeline.tsx +15 -0
  16. package/src/components/story/detail/story-detail-body.tsx +14 -3
  17. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  18. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  19. package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
  20. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  21. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
  22. package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
  23. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  24. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  25. package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
  26. package/src/components/story/peek-panel.tsx +8 -1
  27. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  28. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  29. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  30. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  31. package/src/components/story/relations/file-preview.tsx +173 -0
  32. package/src/components/story/relations/format.ts +120 -0
  33. package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
  34. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  35. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  36. package/src/components/story/relations/story-ref-row.tsx +319 -0
  37. package/src/components/story/story-list-view.tsx +3 -1
  38. package/src/components/story/story-panel-header.tsx +4 -1
  39. package/src/components/story/types.ts +30 -18
  40. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  41. package/src/components/story/views/story-list/index.tsx +16 -5
  42. package/src/components/story/views/story-list/row.tsx +5 -4
  43. package/src/components/ui/separator.tsx +1 -1
  44. package/src/hooks/use-notifications.ts +4 -1
  45. package/src/lib/__tests__/datetime.test.ts +16 -1
  46. package/src/lib/changelog/sdk-versions.ts +19 -19
  47. package/src/lib/datetime.ts +1 -0
  48. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  49. package/src/lib/story/video-embed.ts +59 -0
  50. package/src/routeTree.gen.ts +65 -0
  51. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  52. package/src/routes/_nav/notifications/route.tsx +30 -0
  53. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  54. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  55. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  56. package/src/routes/api/attachments/$id/content.ts +60 -0
  57. package/src/routes/api/attachments/upload.ts +148 -0
  58. package/src/routes/api/notifications/stream.ts +4 -0
  59. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  60. package/src/server/notifications/notification-service.ts +137 -27
  61. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  62. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  63. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  64. package/src/server/serverFns/knowledge.ts +50 -4
  65. package/src/server/serverFns/locale.ts +15 -3
  66. package/src/server/serverFns/notifications.ts +18 -4
  67. package/src/server/serverFns/project.ts +43 -0
  68. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  69. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  70. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
  71. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  72. package/src/server/serverFns/story/index.ts +7 -6
  73. package/src/server/serverFns/story/kb-general.ts +39 -0
  74. package/src/server/serverFns/story/kb-scope.ts +52 -0
  75. package/src/server/serverFns/story/link-preview.ts +115 -0
  76. package/src/server/serverFns/story/story-assoc.ts +339 -50
  77. package/src/server/serverFns/story/story-attachments.ts +12 -43
  78. package/src/server/serverFns/story/story-crud.ts +21 -4
  79. package/src/server/serverFns/story/story-dependencies.ts +138 -162
  80. package/src/server/serverFns/story/story-groups.ts +23 -3
  81. package/src/server/serverFns/story/story-links.ts +13 -1
  82. package/src/server/serverFns/story/story-transfer.ts +5 -3
  83. package/src/server/serverFns/story/story-tree.ts +28 -1
  84. package/src/server/serverFns/story/types.ts +37 -0
  85. package/src/server/serverFns/team.ts +51 -2
  86. package/src/styles/globals.css +25 -0
  87. package/vite.shared.ts +2 -0
  88. package/src/components/notifications/notification-list.tsx +0 -232
  89. package/src/components/notifications/notification-preferences.tsx +0 -156
  90. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  91. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  92. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  93. package/src/components/story/relation-badge.tsx +0 -57
  94. package/src/components/story/relations/attachments-panel.tsx +0 -179
  95. package/src/components/story/relations/dependency-panel.tsx +0 -190
  96. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  97. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  98. package/src/components/story/relations/links-panel.tsx +0 -240
  99. package/src/components/story/relations/tab.tsx +0 -212
@@ -0,0 +1,473 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import * as React from "react";
19
+ import { useNavigate } from "@tanstack/react-router";
20
+ import { useTranslation } from "react-i18next";
21
+ import { Plus, ChevronDown, ChartNoAxesGantt } from "lucide-react";
22
+ import { Button } from "@/components/ui/button";
23
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
24
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
25
+ import { useServerFn } from "@tanstack/react-start";
26
+ import { toast } from "sonner";
27
+ import {
28
+ removeDependencyFn,
29
+ bulkArchiveStoriesFn,
30
+ bulkDeleteStoriesFn,
31
+ bulkUpdateStoriesFn,
32
+ } from "@/server/serverFns/story";
33
+ import { BatchToolbar } from "../../views/story-list/batch-toolbar";
34
+ import { useGroupedStories } from "../../display/use-grouped-stories";
35
+ import { VirtualGroupHeader } from "../../views/story-list/group";
36
+ import { DisplayOptionsBody } from "../../display/display-dropdown";
37
+ import { StoryRefRow } from "../../relations/story-ref-row";
38
+ import type { DisplayOptions, GroupNode } from "../../display/types";
39
+ import type { StoryPatch } from "../../display/group-fields";
40
+ import type { RelationsData, UserBrief, DependencyRow } from "../../types";
41
+
42
+ type DependencySection = "dependsOn" | "dependents";
43
+
44
+ // fetch 网络层失败(服务未启动/断网)时浏览器抛 TypeError,文案为英文原始信息
45
+ function isNetworkError(e: unknown): boolean {
46
+ const msg = e instanceof Error ? e.message : "";
47
+ return /failed to fetch|load failed|network error|fetch failed/i.test(msg);
48
+ }
49
+
50
+ export interface StoryDetailDependencyPanelProps {
51
+ storyId: string;
52
+ /** 当前需求编号(tooltip 文案用) */
53
+ storyIdentifier?: string;
54
+ projectId: string | null;
55
+ data: RelationsData["dependencies"];
56
+ onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
57
+ refreshDependencies: () => void;
58
+ users?: UserBrief[];
59
+ projects?: { id: string; name: string; icon: string; displayId: string | null }[];
60
+ labels?: { id: string; name: string; color: string }[];
61
+ onOpenAdd: () => void;
62
+ }
63
+
64
+ export function StoryDetailDependencyPanel({
65
+ storyId,
66
+ storyIdentifier,
67
+ projectId,
68
+ data,
69
+ onRefresh,
70
+ refreshDependencies,
71
+ users,
72
+ projects,
73
+ labels,
74
+ onOpenAdd,
75
+ }: StoryDetailDependencyPanelProps) {
76
+ const { t } = useTranslation("story");
77
+ const navigate = useNavigate();
78
+ const removeDependency = useServerFn(removeDependencyFn);
79
+ const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
80
+ const [busy, setBusy] = React.useState(false);
81
+ const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({});
82
+ const [filters, setFilters] = React.useState<DisplayOptions>(() => ({
83
+ groupBy: "none",
84
+ subGroupBy: "none",
85
+ orderBy: "created",
86
+ orderDir: "desc",
87
+ properties: ["id", "priority", "state", "assignee", "startDate", "dueDate", "labels"],
88
+ showEmptyGroups: false,
89
+ showSubItems: false,
90
+ pageSize: 100,
91
+ }));
92
+
93
+ const allRows = React.useMemo(
94
+ () => [...data.dependsOn, ...data.dependents],
95
+ [data],
96
+ );
97
+ const rowById = React.useMemo(
98
+ () => new Map(allRows.map((r) => [r.id, r])),
99
+ [allRows],
100
+ );
101
+
102
+ const dependsOnGroups = useGroupedStories({
103
+ stories: data.dependsOn,
104
+ filters,
105
+ users: users ?? [],
106
+ labels: labels ?? [],
107
+ projects: projects ?? [],
108
+ });
109
+ const dependentsGroups = useGroupedStories({
110
+ stories: data.dependents,
111
+ filters,
112
+ users: users ?? [],
113
+ labels: labels ?? [],
114
+ projects: projects ?? [],
115
+ });
116
+
117
+ const selectedStories = React.useMemo(
118
+ () => allRows.filter((s) => selectedIds.has(s.id)),
119
+ [allRows, selectedIds],
120
+ );
121
+ const canArchive = React.useMemo(
122
+ () => selectedStories.some((s) => s.state === "done" || s.state === "cancelled"),
123
+ [selectedStories],
124
+ );
125
+ const allSelected = allRows.length > 0 && selectedIds.size === allRows.length;
126
+ const showSelect = selectedIds.size > 0;
127
+ const totalCount = data.dependsOn.length + data.dependents.length;
128
+
129
+ if (totalCount === 0) {
130
+ return null;
131
+ }
132
+
133
+ const updateFilters = (patch: Partial<DisplayOptions>) => {
134
+ setFilters((prev) => ({ ...prev, ...patch }));
135
+ };
136
+
137
+ const handleOpenStory = (id: string) => {
138
+ navigate({ to: "/stories/$storyId", params: { storyId: id } });
139
+ };
140
+
141
+ const applyRowsUpdate = (
142
+ next: (rows: DependencyRow[]) => DependencyRow[],
143
+ group: DependencySection,
144
+ ) => {
145
+ onRefresh((prev) => ({
146
+ ...prev,
147
+ dependencies: { ...prev.dependencies, [group]: next(prev.dependencies[group]) },
148
+ }));
149
+ };
150
+
151
+ const handleRemoveRow = async (rowId: string) => {
152
+ const row = rowById.get(rowId);
153
+ if (!row) return;
154
+ const group: DependencySection = data.dependsOn.some((r) => r.id === rowId)
155
+ ? "dependsOn"
156
+ : "dependents";
157
+ try {
158
+ await removeDependency({ data: { depId: row.depId } });
159
+ toast.success(t("dependency.removed"));
160
+ applyRowsUpdate((rows) => rows.filter((r) => r.id !== rowId), group);
161
+ setSelectedIds((prev) => {
162
+ const next = new Set(prev);
163
+ next.delete(rowId);
164
+ return next;
165
+ });
166
+ } catch (e) {
167
+ toast.error(isNetworkError(e) ? t("dependency.networkFailed") : (e as Error)?.message || t("dependency.removeFailed"));
168
+ }
169
+ };
170
+
171
+ const handleBulkRemoveRelations = async (storyIds: string[]) => {
172
+ if (storyIds.length === 0) return;
173
+ setBusy(true);
174
+ try {
175
+ for (const id of storyIds) {
176
+ const row = rowById.get(id);
177
+ if (row) await removeDependency({ data: { depId: row.depId } });
178
+ }
179
+ toast.success(t("dependency.removed"));
180
+ setSelectedIds(new Set());
181
+ refreshDependencies();
182
+ } catch (e) {
183
+ toast.error(isNetworkError(e) ? t("dependency.networkFailed") : (e as Error)?.message || t("dependency.removeFailed"));
184
+ } finally {
185
+ setBusy(false);
186
+ }
187
+ };
188
+
189
+ const handleBulkArchive = async (storyIds?: string[]) => {
190
+ const ids = storyIds ?? [...selectedIds];
191
+ if (ids.length === 0) return;
192
+ setBusy(true);
193
+ try {
194
+ const res = await bulkArchiveStoriesFn({ data: { storyIds: ids } });
195
+ toast.success(
196
+ res.skipped > 0
197
+ ? t("children.bulkArchivedSkipped", { archived: res.archived, skipped: res.skipped })
198
+ : t("children.bulkArchived", { count: res.archived }),
199
+ );
200
+ setSelectedIds(new Set());
201
+ refreshDependencies();
202
+ } catch (e) {
203
+ toast.error((e as Error)?.message || t("children.archiveFailed"));
204
+ } finally {
205
+ setBusy(false);
206
+ }
207
+ };
208
+
209
+ const handleBulkDelete = async (storyIds?: string[]) => {
210
+ const ids = storyIds ?? [...selectedIds];
211
+ if (ids.length === 0) return;
212
+ setBusy(true);
213
+ try {
214
+ const res = await bulkDeleteStoriesFn({ data: { storyIds: ids } });
215
+ toast.success(t("children.bulkDeleted", { count: res.deleted }));
216
+ setSelectedIds(new Set());
217
+ refreshDependencies();
218
+ } catch (e) {
219
+ toast.error((e as Error)?.message || t("children.deleteFailed"));
220
+ } finally {
221
+ setBusy(false);
222
+ }
223
+ };
224
+
225
+ const handleBulkUpdate = async (patch: StoryPatch) => {
226
+ if (selectedIds.size === 0) return;
227
+ setBusy(true);
228
+ try {
229
+ await bulkUpdateStoriesFn({ data: { storyIds: [...selectedIds], patch } });
230
+ setSelectedIds(new Set());
231
+ toast.success(t("dependency.bulkUpdated"));
232
+ refreshDependencies();
233
+ } catch (e) {
234
+ toast.error((e as Error)?.message || t("children.bulkUpdateFailed"));
235
+ } finally {
236
+ setBusy(false);
237
+ }
238
+ };
239
+
240
+ const toggleStory = (id: string) => {
241
+ setSelectedIds((prev) => {
242
+ const next = new Set(prev);
243
+ if (next.has(id)) next.delete(id);
244
+ else next.add(id);
245
+ return next;
246
+ });
247
+ };
248
+
249
+ const toggleGroup = (ids: string[]) => {
250
+ setSelectedIds((prev) => {
251
+ const next = new Set(prev);
252
+ const allChosen = ids.length > 0 && ids.every((id) => next.has(id));
253
+ if (allChosen) {
254
+ for (const id of ids) next.delete(id);
255
+ } else {
256
+ for (const id of ids) next.add(id);
257
+ }
258
+ return next;
259
+ });
260
+ };
261
+
262
+ const toggleCollapse = (key: string) => {
263
+ setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
264
+ };
265
+
266
+ const computeGroupCheckState = (ids: string[]): boolean | "indeterminate" => {
267
+ if (ids.length === 0) return false;
268
+ let count = 0;
269
+ for (const id of ids) if (selectedIds.has(id)) count++;
270
+ if (count === ids.length) return true;
271
+ return count > 0 ? "indeterminate" : false;
272
+ };
273
+
274
+ const handleStoryRefresh = (rowId: string, patch: Record<string, unknown>) => {
275
+ for (const group of ["dependsOn", "dependents"] as const) {
276
+ if (patch.deleted) {
277
+ applyRowsUpdate((rows) => rows.filter((r) => r.id !== rowId), group);
278
+ } else {
279
+ applyRowsUpdate(
280
+ (rows) => rows.map((r) => (r.id === rowId ? ({ ...r, ...patch } as DependencyRow) : r)),
281
+ group,
282
+ );
283
+ }
284
+ }
285
+ if (patch.deleted) {
286
+ setSelectedIds((prev) => {
287
+ const next = new Set(prev);
288
+ next.delete(rowId);
289
+ return next;
290
+ });
291
+ } else {
292
+ refreshDependencies();
293
+ }
294
+ };
295
+
296
+ const renderRow = (row: DependencyRow, group: DependencySection) => (
297
+ <StoryRefRow
298
+ key={row.depId}
299
+ story={row}
300
+ users={users ?? []}
301
+ labels={labels ?? []}
302
+ parentProjectId={projectId}
303
+ projects={projects ?? []}
304
+ selected={selectedIds.has(row.id)}
305
+ properties={filters.properties}
306
+ onToggle={toggleStory}
307
+ onOpenStory={handleOpenStory}
308
+ onRemove={handleRemoveRow}
309
+ removeMenuLabel={t("dependency.remove")}
310
+ dependencyMeta={{ depType: row.depType, group }}
311
+ currentIdentifier={storyIdentifier}
312
+ onStoryRefresh={handleStoryRefresh}
313
+ />
314
+ );
315
+
316
+ const renderGroupItems = (node: GroupNode, group: DependencySection, depth: number, collapseKey: string) => {
317
+ const isCollapsed = collapsed[collapseKey] ?? false;
318
+ const ids = node.items.map((s) => s.id);
319
+ return (
320
+ <React.Fragment key={collapseKey}>
321
+ <div style={{ height: 36 }}>
322
+ <VirtualGroupHeader
323
+ node={node}
324
+ depth={depth}
325
+ collapsed={isCollapsed}
326
+ collapseKey={collapseKey}
327
+ onToggle={toggleCollapse}
328
+ selectState={computeGroupCheckState(ids)}
329
+ onToggleSelect={() => toggleGroup(ids)}
330
+ showSelect={showSelect}
331
+ showRoleLabel={
332
+ filters.subGroupBy === "assignee" || filters.subGroupBy === "createdBy"
333
+ }
334
+ groupDimension={depth === 0 ? filters.groupBy : filters.subGroupBy}
335
+ />
336
+ </div>
337
+ {!isCollapsed && (
338
+ <React.Fragment>
339
+ {node.subGroups && node.subGroups.length > 0 ? (
340
+ node.subGroups.map((subGroup) =>
341
+ renderGroupItems(
342
+ subGroup,
343
+ group,
344
+ depth + 1,
345
+ `${collapseKey}/${subGroup.key}`,
346
+ ),
347
+ )
348
+ ) : (
349
+ node.items.map((s) => renderRow(s as DependencyRow, group))
350
+ )}
351
+ </React.Fragment>
352
+ )}
353
+ </React.Fragment>
354
+ );
355
+ };
356
+
357
+ const renderSection = (
358
+ titleKey: string,
359
+ sectionKey: DependencySection,
360
+ rows: DependencyRow[],
361
+ groups: GroupNode[],
362
+ ) => {
363
+ const isCollapsed = collapsed[`${sectionKey}:root`] ?? false;
364
+ return (
365
+ <Collapsible open={!isCollapsed} onOpenChange={(open) => setCollapsed((p) => ({ ...p, [`${sectionKey}:root`]: !open }))}>
366
+ <CollapsibleTrigger asChild>
367
+ <div className="flex h-9 cursor-pointer items-center gap-1.5 bg-muted/50 px-3 text-sm font-medium hover:text-foreground transition-colors">
368
+ <ChevronDown className={`size-3 shrink-0 text-muted-foreground transition-transform ${isCollapsed ? "-rotate-90" : "rotate-0"}`} />
369
+ <span>{t(titleKey)}</span>
370
+ <span className="ml-1 text-xs text-muted-foreground">{rows.length}</span>
371
+ </div>
372
+ </CollapsibleTrigger>
373
+ <CollapsibleContent>
374
+ {rows.length === 0 ? (
375
+ <p className="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground">
376
+ <span className="w-4 shrink-0" aria-hidden />
377
+ {t("relation.none")}
378
+ </p>
379
+ ) : filters.groupBy === "none" ? (
380
+ rows.map((row) => renderRow(row, sectionKey))
381
+ ) : (
382
+ groups.map((node) => renderGroupItems(node, sectionKey, 0, `${sectionKey}:${node.key}`))
383
+ )}
384
+ </CollapsibleContent>
385
+ </Collapsible>
386
+ );
387
+ };
388
+
389
+ return (
390
+ <Collapsible defaultOpen>
391
+ <CollapsibleTrigger
392
+ asChild
393
+ className="group flex w-full cursor-pointer items-center gap-2 pb-3 pt-4 text-xs font-semibold hover:text-foreground transition-colors"
394
+ >
395
+ <div
396
+ role="button"
397
+ tabIndex={0}
398
+ onKeyDown={(e) => {
399
+ if (e.key === "Enter" || e.key === " ") {
400
+ e.preventDefault();
401
+ (e.currentTarget as HTMLDivElement).click();
402
+ }
403
+ }}
404
+ >
405
+ <div className="flex items-center gap-1.5">
406
+ <span>{t("relations.dependency")}</span>
407
+ <span className="inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
408
+ {totalCount}
409
+ </span>
410
+ <ChevronDown className="size-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-0 group-data-[state=closed]:-rotate-90" />
411
+ </div>
412
+ <div className="flex items-center gap-1 ml-auto" onClick={(e) => e.stopPropagation()}>
413
+ <Popover>
414
+ <PopoverTrigger asChild>
415
+ <Button variant="ghost" size="sm" className="size-7 p-0">
416
+ <ChartNoAxesGantt className="size-3.5" />
417
+ </Button>
418
+ </PopoverTrigger>
419
+ <PopoverContent align="end" className="w-75 p-0">
420
+ <div className="max-h-[60vh] overflow-y-auto">
421
+ <DisplayOptionsBody
422
+ filters={filters}
423
+ onChange={updateFilters}
424
+ layout="list"
425
+ showSubItems={false}
426
+ />
427
+ </div>
428
+ </PopoverContent>
429
+ </Popover>
430
+ <Button variant="ghost" size="sm" className="size-7 p-0" onClick={onOpenAdd}>
431
+ <Plus className="size-3.5" />
432
+ </Button>
433
+ </div>
434
+ </div>
435
+ </CollapsibleTrigger>
436
+ <CollapsibleContent className="pb-2">
437
+ <div className="flex flex-col gap-0 border rounded-lg overflow-hidden">
438
+ {renderSection("dependency.dependsOnTitle", "dependsOn", data.dependsOn, dependsOnGroups)}
439
+ {renderSection("dependency.dependentsTitle", "dependents", data.dependents, dependentsGroups)}
440
+ <span className="-ml-1">
441
+ <BatchToolbar
442
+ selectedCount={selectedIds.size}
443
+ totalLoaded={allRows.length}
444
+ allSelected={allSelected}
445
+ canArchive={canArchive}
446
+ canMove={false}
447
+ canMoveUp={false}
448
+ canMoveDown={false}
449
+ busy={busy}
450
+ users={users ?? []}
451
+ labels={labels ?? []}
452
+ projects={projects ?? []}
453
+ stories={selectedStories}
454
+ onToggleAll={() =>
455
+ setSelectedIds(
456
+ allSelected ? new Set() : new Set(allRows.map((r) => r.id)),
457
+ )
458
+ }
459
+ onMoveUp={() => {}}
460
+ onMoveDown={() => {}}
461
+ onArchive={handleBulkArchive}
462
+ onDelete={handleBulkDelete}
463
+ onBulkUpdate={handleBulkUpdate}
464
+ onClearSelection={() => setSelectedIds(new Set())}
465
+ onTransfer={() => {}}
466
+ onRemoveRelations={handleBulkRemoveRelations}
467
+ />
468
+ </span>
469
+ </div>
470
+ </CollapsibleContent>
471
+ </Collapsible>
472
+ );
473
+ }