@allbluecn/web-app 0.5.1 → 0.6.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 (125) hide show
  1. package/package.json +230 -30
  2. package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
  3. package/src/components/iteration/burndown-chart.tsx +63 -0
  4. package/src/components/iteration/constants.ts +28 -0
  5. package/src/components/iteration/create-iteration-dialog.tsx +140 -0
  6. package/src/components/iteration/iteration-card.tsx +60 -0
  7. package/src/components/iteration/iteration-editor.tsx +116 -0
  8. package/src/components/iteration/iteration-progress-bar.tsx +44 -0
  9. package/src/components/iteration/iteration-status-badge.tsx +33 -0
  10. package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
  11. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
  12. package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
  13. package/src/components/project/create-project-dialog.tsx +44 -57
  14. package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
  15. package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
  16. package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
  17. package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
  18. package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
  19. package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
  20. package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
  21. package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
  22. package/src/components/story/__tests__/constants.test.ts +255 -0
  23. package/src/components/story/ai/story-ai-panel.tsx +23 -15
  24. package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
  25. package/src/components/story/constants.ts +33 -1
  26. package/src/components/story/create-story-dialog.tsx +30 -10
  27. package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
  28. package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
  29. package/src/components/story/detail/activity-timeline.tsx +11 -4
  30. package/src/components/story/detail/story-detail-body.tsx +89 -38
  31. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
  32. package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
  33. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
  34. package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
  35. package/src/components/story/detail/story-properties.tsx +21 -4
  36. package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
  37. package/src/components/story/display/group-resolver.tsx +2 -2
  38. package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
  39. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
  40. package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
  41. package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
  42. package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
  43. package/src/components/story/inline-editors/index.ts +2 -1
  44. package/src/components/story/inline-editors/labels-editor.tsx +159 -3
  45. package/src/components/story/inline-editors/story-select-dialog.tsx +22 -59
  46. package/src/components/story/peek-panel.tsx +121 -21
  47. package/src/components/story/relations/link-manage-dialog.tsx +2 -1
  48. package/src/components/story/relations/story-ref-row.tsx +17 -27
  49. package/src/components/story/story-panel-header.tsx +25 -19
  50. package/src/components/story/story-toolbar.tsx +3 -3
  51. package/src/components/story/transfer-story-popover.tsx +2 -2
  52. package/src/components/story/types.ts +1 -0
  53. package/src/components/story/views/create-view-dialog.tsx +3 -3
  54. package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
  55. package/src/components/story/views/story-list/group.tsx +1 -1
  56. package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
  57. package/src/components/story/views/story-list/row.tsx +1 -1
  58. package/src/components/workspace/create-workspace-dialog.tsx +41 -49
  59. package/src/lib/__tests__/project-display-id.test.ts +8 -5
  60. package/src/lib/changelog/sdk-versions.ts +20 -20
  61. package/src/lib/iteration/__tests__/status.test.ts +41 -0
  62. package/src/lib/iteration/status.ts +28 -0
  63. package/src/lib/project-display-id.ts +14 -5
  64. package/src/lib/project-module-meta.ts +77 -0
  65. package/src/lib/utils.d.ts +1 -1
  66. package/src/providers/index.tsx +6 -0
  67. package/src/routeTree.gen.ts +54 -0
  68. package/src/routes/__root.tsx +0 -8
  69. package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
  70. package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
  71. package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
  72. package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
  73. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
  74. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
  75. package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
  76. package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
  77. package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
  78. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +4 -4
  79. package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
  80. package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
  81. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
  82. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
  83. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  84. package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
  85. package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
  86. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
  87. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
  88. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
  89. package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
  90. package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
  91. package/src/server/serverFns/iteration/burndown-core.ts +60 -0
  92. package/src/server/serverFns/iteration/index.ts +22 -0
  93. package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
  94. package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
  95. package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
  96. package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
  97. package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
  98. package/src/server/serverFns/iteration/schemas.ts +58 -0
  99. package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
  100. package/src/server/serverFns/project/module-overview.ts +109 -0
  101. package/src/server/serverFns/project.ts +2 -2
  102. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
  103. package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
  104. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +106 -2
  105. package/src/server/serverFns/story/index.ts +3 -0
  106. package/src/server/serverFns/story/story-assoc.ts +36 -4
  107. package/src/server/serverFns/story/story-attachments.ts +20 -2
  108. package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
  109. package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
  110. package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
  111. package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
  112. package/src/server/serverFns/story/story-crud/labels.ts +3 -5
  113. package/src/server/serverFns/story/story-crud.ts +32 -2
  114. package/src/server/serverFns/story/story-dependencies.ts +123 -2
  115. package/src/server/serverFns/story/story-links.ts +30 -6
  116. package/src/server/serverFns/story/story-transfer.ts +5 -4
  117. package/src/server/serverFns/story/types.ts +1 -0
  118. package/src/types/project.ts +5 -5
  119. package/src/components/project/project-animated-icon.tsx +0 -73
  120. package/src/components/shared/icon-packs/index.ts +0 -1
  121. package/src/components/shared/icon-packs/knowledge.ts +0 -1
  122. package/src/components/shared/icon-packs/prd.ts +0 -1
  123. package/src/components/shared/icon-packs/project.ts +0 -1
  124. package/src/components/shared/icon-picker.tsx +0 -1
  125. package/src/lib/project-mock.ts +0 -156
@@ -0,0 +1,289 @@
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 { useState } from "react";
19
+ import { createLazyFileRoute, Link, useLoaderData, useRouter } from "@tanstack/react-router";
20
+ import { useTranslation } from "react-i18next";
21
+ import { CalendarRange, MoreVertical, Pencil, Trash2, Archive, ArchiveX, Plus } from "lucide-react";
22
+ import { Button } from "@/components/ui/button";
23
+ import { IterationStatusBadge } from "@/components/iteration/iteration-status-badge";
24
+ import { IterationProgressBar } from "@/components/iteration/iteration-progress-bar";
25
+ import { BurndownChart } from "@/components/iteration/burndown-chart";
26
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
27
+ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
28
+ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
29
+ import { StorySelectDialog } from "@/components/story/inline-editors/story-select-dialog";
30
+ import { listIterationStoriesFn, archiveIterationsFn, deleteIterationsFn, transferUnfinishedFn, getIterationBurndownFn, listIterationsFn, addStoriesToIterationFn } from "@/server/serverFns/iteration";
31
+ import { listProjectsBriefFn } from "@/server/serverFns/project";
32
+ import { toast } from "@/components/ui/sonner";
33
+ import { useQuery } from "@tanstack/react-query";
34
+ import type { IterationSummary } from "@/server/serverFns/iteration/schemas";
35
+ import type { StoryState } from "@/components/story/constants";
36
+
37
+ interface IterationStoryRow {
38
+ id: string;
39
+ identifier: string;
40
+ name: string;
41
+ state: StoryState;
42
+ priority: string;
43
+ assignee?: { id: string; username: string; email: string; avatarConfig?: any; bgShape?: string; bgColor?: string } | null;
44
+ }
45
+
46
+ export const Route = createLazyFileRoute("/_nav/projects/$projectId/iterations/$iterationId")({
47
+ component: IterationDetailPage,
48
+ });
49
+
50
+ function IterationDetailPage() {
51
+ const { t } = useTranslation("iterations");
52
+ const { iteration, stories } = useLoaderData({ from: "/_nav/projects/$projectId/iterations/$iterationId" }) as {
53
+ iteration: IterationSummary;
54
+ stories: { items: IterationStoryRow[] };
55
+ };
56
+ const projectId = Route.useParams().projectId;
57
+ const iterationId = Route.useParams().iterationId;
58
+ const today = new Date().toISOString().slice(0, 10);
59
+ const remainingDays = Math.round((Date.parse(iteration.endDate) - Date.parse(today)) / 86400000);
60
+ const isCompleted = iteration.status === "completed";
61
+ const isOverdue = remainingDays < 0;
62
+ const [transferOpen, setTransferOpen] = useState(false);
63
+ const [addStoryOpen, setAddStoryOpen] = useState(false);
64
+ const router = useRouter();
65
+
66
+ const { data: burndown, isLoading: burndownLoading } = useQuery({
67
+ queryKey: ["iteration-burndown", iterationId],
68
+ queryFn: () => getIterationBurndownFn({ data: { iterationId } }),
69
+ enabled: iteration.storyCount > 0,
70
+ });
71
+
72
+ async function handleRemoveStory(storyId: string) {
73
+ await import("@/server/serverFns/iteration/iteration-stories").then((m) => m.removeStoryFromIterationFn({ data: { storyId } }));
74
+ toast.success(t("actions.removeStory"));
75
+ }
76
+
77
+ return (
78
+ <div className="h-full overflow-y-auto">
79
+ <div className="mx-auto max-w-4xl px-6 py-6">
80
+ {/* Header */}
81
+ <div className="mb-6 flex items-start justify-between">
82
+ <div>
83
+ <Link to="/projects/$projectId/iterations" params={{ projectId }} className="text-sm text-muted-foreground hover:text-foreground">
84
+ ← {t("title")}
85
+ </Link>
86
+ <div className="mt-2 flex items-center gap-3">
87
+ <h1 className="text-2xl font-semibold">{iteration.name}</h1>
88
+ <IterationStatusBadge status={iteration.status} />
89
+ </div>
90
+ <p className="mt-1 text-sm text-muted-foreground">
91
+ {iteration.startDate} ~ {iteration.endDate}
92
+ {iteration.status === "active" && !isOverdue && (
93
+ <span className="ml-2">({t("progress.remainingDays", { days: remainingDays })})</span>
94
+ )}
95
+ {isOverdue && (
96
+ <span className="ml-2 text-red-500">{t("progress.overdueDays", { days: Math.abs(remainingDays) })}</span>
97
+ )}
98
+ </p>
99
+ </div>
100
+ <div className="flex items-center gap-2">
101
+ <Button variant="outline" size="sm" disabled={isCompleted}>
102
+ <Pencil className="mr-1 size-4" />
103
+ {t("actions.edit")}
104
+ </Button>
105
+ <DropdownMenu>
106
+ <DropdownMenuTrigger asChild>
107
+ <Button variant="outline" size="sm">
108
+ <MoreVertical className="size-4" />
109
+ </Button>
110
+ </DropdownMenuTrigger>
111
+ <DropdownMenuContent align="end">
112
+ <DropdownMenuItem onClick={() => void (async () => {
113
+ await archiveIterationsFn({ data: { id: iterationId, archived: !iteration.archived } });
114
+ toast.success(iteration.archived ? t("actions.unarchive") : t("actions.archive"));
115
+ })()}>
116
+ {iteration.archived ? <ArchiveX className="mr-2 size-4" /> : <Archive className="mr-2 size-4" />}
117
+ {iteration.archived ? t("actions.unarchive") : t("actions.archive")}
118
+ </DropdownMenuItem>
119
+ <DropdownMenuItem className="text-destructive" onClick={() => document.getElementById(`delete-iter-${iterationId}`)?.click()}>
120
+ <Trash2 className="mr-2 size-4" />
121
+ {t("actions.delete")}
122
+ </DropdownMenuItem>
123
+ </DropdownMenuContent>
124
+ </DropdownMenu>
125
+ <AlertDialog>
126
+ <AlertDialogTrigger asChild>
127
+ <Button variant="destructive" size="sm" id={`delete-iter-${iterationId}`} className="hidden">Delete</Button>
128
+ </AlertDialogTrigger>
129
+ <AlertDialogContent>
130
+ <AlertDialogHeader>
131
+ <AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
132
+ <AlertDialogDescription>{t("errors.deleteConfirm")}</AlertDialogDescription>
133
+ </AlertDialogHeader>
134
+ <AlertDialogFooter>
135
+ <AlertDialogCancel>{t("actions.cancel")}</AlertDialogCancel>
136
+ <AlertDialogAction
137
+ onClick={async () => {
138
+ await deleteIterationsFn({ data: { id: iterationId } });
139
+ toast.success(t("actions.delete"));
140
+ void router.navigate({ to: "/projects/$projectId/iterations", params: { projectId } });
141
+ }}
142
+ >
143
+ {t("actions.delete")}
144
+ </AlertDialogAction>
145
+ </AlertDialogFooter>
146
+ </AlertDialogContent>
147
+ </AlertDialog>
148
+ {isCompleted && (
149
+ <Button variant="outline" size="sm" onClick={() => setTransferOpen(true)}>
150
+ {t("actions.transfer")}
151
+ </Button>
152
+ )}
153
+ {!isCompleted && (
154
+ <Button variant="outline" size="sm" onClick={() => setAddStoryOpen(true)}>
155
+ <Plus className="mr-1 size-4" />
156
+ {t("actions.addStories")}
157
+ </Button>
158
+ )}
159
+ </div>
160
+ </div>
161
+
162
+ {/* Stats + Burndown */}
163
+ {iteration.storyCount > 0 && (
164
+ <div className="mb-6 space-y-4">
165
+ <div className="rounded-lg border bg-card p-4">
166
+ <IterationProgressBar stateCounts={iteration.stateCounts} />
167
+ </div>
168
+ <div className="rounded-lg border bg-card p-4">
169
+ {burndownLoading ? (
170
+ <div className="flex h-48 items-center justify-center text-sm text-muted-foreground">加载中...</div>
171
+ ) : burndown ? (
172
+ <BurndownChart data={burndown} />
173
+ ) : null}
174
+ </div>
175
+ </div>
176
+ )}
177
+
178
+ {/* Stories list */}
179
+ <div className="space-y-2">
180
+ {stories.items.length === 0 ? (
181
+ <p className="py-8 text-center text-sm text-muted-foreground">{t("empty.description")}</p>
182
+ ) : (
183
+ stories.items.map((story) => (
184
+ <div key={story.id} className="flex items-center gap-3 rounded-lg border bg-card px-4 py-3">
185
+ <span className="font-mono text-xs text-muted-foreground">{story.identifier}</span>
186
+ <Link to="/stories/$storyId" params={{ storyId: story.id }} className="flex-1 text-sm font-medium hover:underline">
187
+ {story.name}
188
+ </Link>
189
+ <span className="text-xs text-muted-foreground">{story.state}</span>
190
+ <Button variant="ghost" size="sm" className="ml-auto text-muted-foreground" onClick={() => void handleRemoveStory(story.id)}>
191
+ {t("actions.removeStory")}
192
+ </Button>
193
+ </div>
194
+ ))
195
+ )}
196
+ </div>
197
+ </div>
198
+
199
+ {/* Transfer dialog */}
200
+ <TransferDialog open={transferOpen} onOpenChange={setTransferOpen} iterationId={iterationId} projectId={projectId} onSaved={() => void router.invalidate()} />
201
+ <AddStoriesDialog open={addStoryOpen} onOpenChange={setAddStoryOpen} iterationId={iterationId} projectId={projectId} onAdded={() => void router.invalidate()} />
202
+ </div>
203
+ );
204
+ }
205
+
206
+ function TransferDialog({ open, onOpenChange, iterationId, projectId, onSaved }: { open: boolean; onOpenChange: (v: boolean) => void; iterationId: string; projectId: string; onSaved?: () => void }) {
207
+ const { t } = useTranslation("iterations");
208
+ const [targetId, setTargetId] = useState("");
209
+ const [loading, setLoading] = useState(false);
210
+ const { data } = useQuery({
211
+ queryKey: ["iterations-list", projectId],
212
+ queryFn: () => listIterationsFn({ data: { projectId, includeArchived: false } }),
213
+ enabled: open,
214
+ });
215
+ const targets = (data?.items ?? []).filter((i: IterationSummary) => i.status !== "completed");
216
+
217
+ async function handleTransfer() {
218
+ if (!targetId) return;
219
+ setLoading(true);
220
+ try {
221
+ const r = await transferUnfinishedFn({ data: { iterationId, targetIterationId: targetId } });
222
+ toast.success(t("transfer.success", { count: r.count }));
223
+ onSaved?.();
224
+ onOpenChange(false);
225
+ } catch (e) {
226
+ toast.error(e instanceof Error ? e.message : "转移失败");
227
+ } finally {
228
+ setLoading(false);
229
+ }
230
+ }
231
+
232
+ return (
233
+ <Dialog open={open} onOpenChange={onOpenChange}>
234
+ <DialogContent className="sm:max-w-md">
235
+ <DialogHeader>
236
+ <DialogTitle>{t("transfer.title")}</DialogTitle>
237
+ </DialogHeader>
238
+ <p className="text-sm text-muted-foreground">{t("transfer.description")}</p>
239
+ <div className="space-y-2 py-4">
240
+ <label className="text-sm font-medium">{t("transfer.target")}</label>
241
+ <select
242
+ className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors"
243
+ value={targetId}
244
+ onChange={(e) => setTargetId(e.target.value)}
245
+ >
246
+ <option value="">{t("transfer.noTarget") ?? "请选择目标迭代"}</option>
247
+ {targets.map((i: IterationSummary) => (
248
+ <option key={i.id} value={i.id}>{i.name} ({i.startDate} ~ {i.endDate})</option>
249
+ ))}
250
+ </select>
251
+ </div>
252
+ <DialogFooter>
253
+ <Button variant="outline" onClick={() => onOpenChange(false)}>{t("actions.cancel")}</Button>
254
+ <Button onClick={() => void handleTransfer()} disabled={!targetId || loading}>{loading ? "转移中..." : t("actions.transfer")}</Button>
255
+ </DialogFooter>
256
+ </DialogContent>
257
+ </Dialog>
258
+ );
259
+ }
260
+
261
+ function AddStoriesDialog({ open, onOpenChange, iterationId, projectId, onAdded }: { open: boolean; onOpenChange: (v: boolean) => void; iterationId: string; projectId: string; onAdded?: () => void }) {
262
+ const { t } = useTranslation("iterations");
263
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
264
+ const { data: projects } = useQuery({
265
+ queryKey: ["projects-brief"],
266
+ queryFn: () => listProjectsBriefFn(),
267
+ });
268
+
269
+ async function handleSelected(ids: string | string[]) {
270
+ const storyIds = Array.isArray(ids) ? ids : [ids];
271
+ if (!storyIds.length) return;
272
+ await addStoriesToIterationFn({ data: { iterationId, storyIds } });
273
+ toast.success(t("actions.addStories"));
274
+ onAdded?.();
275
+ onOpenChange(false);
276
+ }
277
+
278
+ return (
279
+ <StorySelectDialog
280
+ open={open}
281
+ onOpenChange={onOpenChange}
282
+ storyId=""
283
+ projects={projects ?? []}
284
+ mode="iteration"
285
+ title={t("actions.addStories")}
286
+ onSelected={handleSelected}
287
+ />
288
+ );
289
+ }
@@ -0,0 +1,27 @@
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 { createFileRoute, notFound } from "@tanstack/react-router";
19
+ import { getIterationFn, listIterationStoriesFn } from "@/server/serverFns/iteration";
20
+
21
+ export const Route = createFileRoute("/_nav/projects/$projectId/iterations/$iterationId")({
22
+ loader: async ({ params }) => {
23
+ const iteration = await getIterationFn({ data: { id: params.iterationId } });
24
+ const stories = await listIterationStoriesFn({ data: { iterationId: params.iterationId } });
25
+ return { iteration, stories };
26
+ },
27
+ });
@@ -0,0 +1,111 @@
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 { useState } from "react";
19
+ import { createLazyFileRoute, Link, useLoaderData, useRouter } from "@tanstack/react-router";
20
+ import { useTranslation } from "react-i18next";
21
+ import { CalendarRange, Plus, Settings } from "lucide-react";
22
+ import { Button } from "@/components/ui/button";
23
+ import { PlaceholderPage } from "@/components/shared/placeholder-page";
24
+ import { IterationCard } from "@/components/iteration/iteration-card";
25
+ import { CreateIterationDialog } from "@/components/iteration/create-iteration-dialog";
26
+ import { AutoScheduleDialog } from "@/components/iteration/auto-schedule-dialog";
27
+ import { listIterationsFn } from "@/server/serverFns/iteration";
28
+ import type { IterationSummary } from "@/server/serverFns/iteration/schemas";
29
+
30
+ export const Route = createLazyFileRoute("/_nav/projects/$projectId/iterations/")({
31
+ component: IterationsPage,
32
+ });
33
+
34
+ function IterationsPage() {
35
+ const { t } = useTranslation("iterations");
36
+ const router = useRouter();
37
+ const { items } = useLoaderData({ from: "/_nav/projects/$projectId/iterations/" });
38
+ const projectId = Route.useParams().projectId;
39
+ const [dialogOpen, setDialogOpen] = useState(false);
40
+ const [scheduleOpen, setScheduleOpen] = useState(false);
41
+
42
+ const active = items.filter((i) => i.status === "active").sort((a, b) => a.startDate.localeCompare(b.startDate));
43
+ const upcoming = items.filter((i) => i.status === "upcoming").sort((a, b) => a.startDate.localeCompare(b.startDate));
44
+ const completed = items.filter((i) => i.status === "completed").sort((a, b) => b.startDate.localeCompare(a.startDate)).slice(0, 8);
45
+
46
+ const groups = [
47
+ { title: t("group.active"), items: active },
48
+ { title: t("group.upcoming"), items: upcoming },
49
+ { title: t("group.completed"), items: completed },
50
+ ].filter((g) => g.items.length > 0);
51
+
52
+ return (
53
+ <div className="h-full overflow-y-auto">
54
+ <div className="mx-auto max-w-4xl px-6 py-6">
55
+ <div className="mb-6 flex items-center justify-between">
56
+ <Link
57
+ to="/projects/$projectId"
58
+ params={{ projectId }}
59
+ className="text-sm text-muted-foreground hover:text-foreground"
60
+ >
61
+ ← {t("title")}
62
+ </Link>
63
+ <div className="flex items-center gap-2">
64
+ <Button variant="outline" size="sm" onClick={() => setScheduleOpen(true)}>
65
+ <Settings className="mr-1 size-4" />
66
+ {t("actions.autoSchedule")}
67
+ </Button>
68
+ <Button size="sm" onClick={() => setDialogOpen(true)}>
69
+ <Plus className="mr-1 size-4" />
70
+ {t("actions.create")}
71
+ </Button>
72
+ </div>
73
+ </div>
74
+
75
+ {groups.length === 0 ? (
76
+ <PlaceholderPage
77
+ title={t("empty.title")}
78
+ description={t("empty.description")}
79
+ icon={CalendarRange}
80
+ />
81
+ ) : (
82
+ <div className="space-y-8">
83
+ {groups.map((group) => (
84
+ <div key={group.title}>
85
+ <h2 className="mb-3 text-sm font-semibold text-muted-foreground">{group.title}</h2>
86
+ <div className="grid gap-3 sm:grid-cols-2">
87
+ {group.items.map((item) => (
88
+ <IterationCard key={item.id} item={item} projectId={projectId} />
89
+ ))}
90
+ </div>
91
+ </div>
92
+ ))}
93
+ </div>
94
+ )}
95
+ </div>
96
+
97
+ <CreateIterationDialog
98
+ open={dialogOpen}
99
+ onOpenChange={setDialogOpen}
100
+ projectId={projectId}
101
+ onSaved={() => void router.invalidate()}
102
+ />
103
+ <AutoScheduleDialog
104
+ open={scheduleOpen}
105
+ onOpenChange={setScheduleOpen}
106
+ projectId={projectId}
107
+ onSaved={() => void router.invalidate()}
108
+ />
109
+ </div>
110
+ );
111
+ }
@@ -0,0 +1,24 @@
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 { createFileRoute } from "@tanstack/react-router";
19
+ import { listIterationsFn } from "@/server/serverFns/iteration";
20
+
21
+ export const Route = createFileRoute("/_nav/projects/$projectId/iterations/")({
22
+ loader: async ({ params }) =>
23
+ listIterationsFn({ data: { projectId: params.projectId, includeArchived: false } }),
24
+ });
@@ -21,16 +21,18 @@ import {
21
21
  listProjectMembersFn,
22
22
  listProjectActivitiesFn,
23
23
  } from "@/server/serverFns/project";
24
+ import { getModuleOverviewFn } from "@/server/serverFns/project/module-overview";
24
25
 
25
26
  export const Route = createFileRoute("/_nav/projects/$projectId")({
26
27
  loader: async ({ params }) => {
27
- const [project, members, activities] = await Promise.all([
28
+ const [project, members, activities, moduleOverview] = await Promise.all([
28
29
  getProjectDetailFn({ data: { projectId: params.projectId } }),
29
30
  listProjectMembersFn({ data: { projectId: params.projectId } }),
30
31
  listProjectActivitiesFn({
31
32
  data: { projectId: params.projectId, take: 15 },
32
33
  }),
34
+ getModuleOverviewFn({ data: { projectId: params.projectId } }),
33
35
  ]);
34
- return { project, members, activities };
36
+ return { project, members, activities, moduleOverview };
35
37
  },
36
38
  });
@@ -28,8 +28,8 @@ import { Label } from "@/components/ui/label";
28
28
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
29
29
  import { Checkbox } from "@/components/ui/checkbox";
30
30
  import { Badge } from "@/components/ui/badge";
31
- import { IconPicker } from "@/components/shared/icon-picker";
32
- import { PROJECT_ICONS } from "@/components/shared/icon-packs/project";
31
+ import { LucideIconPicker } from "@/components/shared/lucide-icon-picker";
32
+
33
33
  import { NameConfirmDialog } from "@/components/shared/name-confirm-dialog";
34
34
  import { ProjectMemberManager } from "@/components/project/project-member-manager";
35
35
  import { extractDisplayId, validateDisplayId } from "@/lib/project-display-id";
@@ -149,9 +149,9 @@ function ProjectSettingsPage() {
149
149
  <CardTitle>{t('settings.basicInfo')}</CardTitle>
150
150
  </CardHeader>
151
151
  <CardContent className="space-y-5">
152
- <div className="flex flex-col gap-3">
152
+ <div className="flex items-center gap-3">
153
153
  <Label>{t('settings.projectIcon')}</Label>
154
- <IconPicker icons={PROJECT_ICONS} value={icon} onChange={setIcon} />
154
+ <LucideIconPicker value={icon} onChange={setIcon} />
155
155
  </div>
156
156
  <div className="grid gap-2">
157
157
  <Label htmlFor="project-name">{t('settings.projectName')}</Label>
@@ -23,7 +23,7 @@ import { StoryDetailBody } from "@/components/story/detail/story-detail-body";
23
23
  import { StoryPanelHeader } from "@/components/story/story-panel-header";
24
24
 
25
25
  function StoryDetailPage() {
26
- const { story, users, comments, activities, neighbors, projects, labels, stories, relationsData } = Route.useLoaderData();
26
+ const { story, users, comments, activities, neighbors, projects, labels, stories, relationsData, iterations } = Route.useLoaderData();
27
27
  const currentUserId = Route.useRouteContext().session?.user?.id;
28
28
  const [storyState, setStoryState] = useState<StoryDetail>(story);
29
29
  const [activitiesState, setActivitiesState] = useState<ActivityItem[]>(activities);
@@ -49,6 +49,7 @@ function StoryDetailPage() {
49
49
  labels={labels}
50
50
  stories={stories}
51
51
  relationsData={relationsState}
52
+ iterations={iterations}
52
53
  onStoryUpdate={(s) => setStoryState(s)}
53
54
  onAppendActivities={(items) => setActivitiesState((prev) => [...items, ...prev])}
54
55
  onRelationsUpdate={(next) => setRelationsState(typeof next === "function" ? next(relationsState) : next)}
@@ -68,6 +69,7 @@ function StoryDetailContent({
68
69
  labels,
69
70
  stories,
70
71
  relationsData,
72
+ iterations,
71
73
  onStoryUpdate,
72
74
  onAppendActivities,
73
75
  onRelationsUpdate,
@@ -82,6 +84,7 @@ function StoryDetailContent({
82
84
  labels: { id: string; name: string; color: string }[];
83
85
  stories: { id: string; identifier: string; name: string; state: StoryState }[];
84
86
  relationsData: RelationsData;
87
+ iterations: import("@/server/serverFns/iteration/schemas").IterationSummary[];
85
88
  onStoryUpdate: (s: StoryDetail) => void;
86
89
  onAppendActivities: (items: ActivityItem[]) => void;
87
90
  onRelationsUpdate: (next: RelationsData | ((prev: RelationsData) => RelationsData)) => void;
@@ -123,6 +126,7 @@ function StoryDetailContent({
123
126
  stories={stories}
124
127
  projects={projects}
125
128
  relationsData={relationsData}
129
+ iterations={iterations}
126
130
  onRelationsUpdate={onRelationsUpdate}
127
131
  onStoryUpdate={onStoryUpdate}
128
132
  onAppendActivities={onAppendActivities}
@@ -33,6 +33,7 @@ import {
33
33
  getLinkedDocsFn,
34
34
  } from "@/server/serverFns/story";
35
35
  import { listProjectsBriefFn } from "@/server/serverFns/project";
36
+ import { listIterationsFn } from "@/server/serverFns/iteration";
36
37
  import type { RelationsData, StoryListItem } from "@/components/story/types";
37
38
 
38
39
  function StoryDetailSkeleton() {
@@ -98,6 +99,7 @@ export const Route = createFileRoute("/_nav/stories/$storyId")({
98
99
  attachments,
99
100
  prds,
100
101
  docs,
102
+ iterationsResult,
101
103
  ] = await Promise.all([
102
104
  listUsersBriefFn({ data: story.projectId ? { projectId: story.projectId } : undefined }),
103
105
  listCommentsFn({ data: { storyId: params.storyId } }),
@@ -112,6 +114,7 @@ export const Route = createFileRoute("/_nav/stories/$storyId")({
112
114
  getAttachmentsFn({ data: { storyId: params.storyId } }),
113
115
  getLinkedPrdsFn({ data: { storyId: params.storyId } }),
114
116
  getLinkedDocsFn({ data: { storyId: params.storyId } }),
117
+ story.projectId ? listIterationsFn({ data: { projectId: story.projectId, includeArchived: false } }) : Promise.resolve({ items: [] }),
115
118
  ]);
116
119
 
117
120
  const relationsData: RelationsData = {
@@ -136,6 +139,7 @@ export const Route = createFileRoute("/_nav/stories/$storyId")({
136
139
  labels,
137
140
  stories: storiesResult.items,
138
141
  relationsData,
142
+ iterations: iterationsResult.items,
139
143
  };
140
144
  },
141
145
  pendingComponent: StoryDetailSkeleton,
@@ -23,7 +23,7 @@ import { useTranslation } from "react-i18next";
23
23
  import { Button } from "@/components/ui/button";
24
24
  import { Card } from "@/components/ui/card";
25
25
  import { GlyphMatrix } from "@/components/magicui/glyph-matrix";
26
- import { ProjectIconDisplay } from "@/components/project/project-icon";
26
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
27
27
  import { CreateProjectDialog } from "@/components/project/create-project-dialog";
28
28
 
29
29
  type DetailData = ReturnType<typeof import("@/server/serverFns/workspace")["getWorkspaceDetailFn"]> extends Promise<infer T>
@@ -52,7 +52,7 @@ function WorkspaceDetailPage() {
52
52
  <div className="relative z-10 mx-auto max-w-6xl px-6 py-6">
53
53
  <div className="flex flex-col items-center text-center py-10 mb-8">
54
54
  <div className="flex size-16 items-center justify-center rounded-xl bg-primary/10 mb-4">
55
- <ProjectIconDisplay name={workspace.icon} size={32} className="text-primary" />
55
+ <LucideIconByName name={workspace.icon} size={32} className="text-primary" />
56
56
  </div>
57
57
  <div className="flex items-center justify-center gap-3">
58
58
  <h1 className="text-2xl font-semibold">{workspace.name}</h1>
@@ -105,7 +105,7 @@ function WorkspaceDetailPage() {
105
105
  />
106
106
  <div className="flex items-start justify-between">
107
107
  <div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
108
- <ProjectIconDisplay name={project.icon} size={20} />
108
+ <LucideIconByName name={project.icon} size={20} />
109
109
  </div>
110
110
  </div>
111
111
  <h3 className="mt-4 font-medium text-lg">{project.name}</h3>
@@ -26,8 +26,8 @@ import { Input } from "@/components/ui/input";
26
26
  import { Textarea } from "@/components/ui/textarea";
27
27
  import { Label } from "@/components/ui/label";
28
28
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
29
- import { IconPicker } from "@/components/shared/icon-picker";
30
- import { PROJECT_ICONS } from "@/components/shared/icon-packs/project";
29
+ import { LucideIconPicker } from "@/components/shared/lucide-icon-picker";
30
+
31
31
  import { NameConfirmDialog } from "@/components/shared/name-confirm-dialog";
32
32
  import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
33
33
  import {
@@ -121,9 +121,9 @@ function WorkspaceSettingsPage() {
121
121
  <CardTitle>{t("settings.basicInfo")}</CardTitle>
122
122
  </CardHeader>
123
123
  <CardContent className="space-y-5">
124
- <div className="flex flex-col gap-3">
124
+ <div className="flex items-center gap-3">
125
125
  <Label>{t("settings.icon")}</Label>
126
- <IconPicker icons={PROJECT_ICONS} value={icon} onChange={setIcon} />
126
+ <LucideIconPicker value={icon} onChange={setIcon} />
127
127
  </div>
128
128
  <div className="grid gap-2">
129
129
  <Label htmlFor="ws-name">{t("settings.nameLabel")}</Label>
@@ -23,7 +23,7 @@ import { useTranslation } from "react-i18next";
23
23
  import { Button } from "@/components/ui/button";
24
24
  import { Card } from "@/components/ui/card";
25
25
  import { GlyphMatrix } from "@/components/magicui/glyph-matrix";
26
- import { ProjectIconDisplay } from "@/components/project/project-icon";
26
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
27
27
  import { CreateWorkspaceDialog } from "@/components/workspace/create-workspace-dialog";
28
28
 
29
29
  type WorkspaceList = ReturnType<typeof import("@/server/serverFns/workspace")["listWorkspacesFn"]> extends Promise<infer T> ? T : never;
@@ -43,7 +43,7 @@ function WorkspacesIndexPage() {
43
43
  <div className="relative z-10 mx-auto max-w-6xl px-6 py-6">
44
44
  <div className="relative mb-10 mt-2 flex flex-col items-center py-10 text-center">
45
45
  <div className="flex size-14 items-center justify-center rounded-xl bg-primary/10 mb-4">
46
- <ProjectIconDisplay name="folder-kanban" size={28} className="text-primary" />
46
+ <LucideIconByName name="folder-kanban" size={28} className="text-primary" />
47
47
  </div>
48
48
  <h1 className="text-2xl font-semibold">{t("list.title")}</h1>
49
49
  <p className="mt-2 max-w-2xl text-sm text-muted-foreground">
@@ -77,7 +77,7 @@ function WorkspacesIndexPage() {
77
77
  <div className="flex-1">
78
78
  <div className="flex items-start justify-between">
79
79
  <div className="flex size-10 items-center justify-center rounded-lg bg-primary/10">
80
- <ProjectIconDisplay name={workspace.icon} size={20} />
80
+ <LucideIconByName name={workspace.icon} size={20} />
81
81
  </div>
82
82
  </div>
83
83
  <h3 className="mt-4 font-medium text-lg">{workspace.name}</h3>