@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.
- package/package.json +230 -30
- package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
- package/src/components/iteration/burndown-chart.tsx +63 -0
- package/src/components/iteration/constants.ts +28 -0
- package/src/components/iteration/create-iteration-dialog.tsx +140 -0
- package/src/components/iteration/iteration-card.tsx +60 -0
- package/src/components/iteration/iteration-editor.tsx +116 -0
- package/src/components/iteration/iteration-progress-bar.tsx +44 -0
- package/src/components/iteration/iteration-status-badge.tsx +33 -0
- package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
- package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
- package/src/components/project/create-project-dialog.tsx +44 -57
- package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
- package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
- package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
- package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
- package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
- package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
- package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
- package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
- package/src/components/story/__tests__/constants.test.ts +255 -0
- package/src/components/story/ai/story-ai-panel.tsx +23 -15
- package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
- package/src/components/story/constants.ts +33 -1
- package/src/components/story/create-story-dialog.tsx +30 -10
- package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
- package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
- package/src/components/story/detail/activity-timeline.tsx +11 -4
- package/src/components/story/detail/story-detail-body.tsx +89 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
- package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
- package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
- package/src/components/story/detail/story-properties.tsx +21 -4
- package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
- package/src/components/story/display/group-resolver.tsx +2 -2
- package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
- package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
- package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
- package/src/components/story/inline-editors/index.ts +2 -1
- package/src/components/story/inline-editors/labels-editor.tsx +159 -3
- package/src/components/story/inline-editors/story-select-dialog.tsx +22 -59
- package/src/components/story/peek-panel.tsx +121 -21
- package/src/components/story/relations/link-manage-dialog.tsx +2 -1
- package/src/components/story/relations/story-ref-row.tsx +17 -27
- package/src/components/story/story-panel-header.tsx +25 -19
- package/src/components/story/story-toolbar.tsx +3 -3
- package/src/components/story/transfer-story-popover.tsx +2 -2
- package/src/components/story/types.ts +1 -0
- package/src/components/story/views/create-view-dialog.tsx +3 -3
- package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
- package/src/components/story/views/story-list/group.tsx +1 -1
- package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
- package/src/components/story/views/story-list/row.tsx +1 -1
- package/src/components/workspace/create-workspace-dialog.tsx +41 -49
- package/src/lib/__tests__/project-display-id.test.ts +8 -5
- package/src/lib/changelog/sdk-versions.ts +20 -20
- package/src/lib/iteration/__tests__/status.test.ts +41 -0
- package/src/lib/iteration/status.ts +28 -0
- package/src/lib/project-display-id.ts +14 -5
- package/src/lib/project-module-meta.ts +77 -0
- package/src/lib/utils.d.ts +1 -1
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +54 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
- package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
- package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
- package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +4 -4
- package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
- package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
- package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
- package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
- package/src/server/serverFns/iteration/burndown-core.ts +60 -0
- package/src/server/serverFns/iteration/index.ts +22 -0
- package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
- package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
- package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
- package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
- package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
- package/src/server/serverFns/iteration/schemas.ts +58 -0
- package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
- package/src/server/serverFns/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +2 -2
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
- package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +106 -2
- package/src/server/serverFns/story/index.ts +3 -0
- package/src/server/serverFns/story/story-assoc.ts +36 -4
- package/src/server/serverFns/story/story-attachments.ts +20 -2
- package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
- package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
- package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
- package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
- package/src/server/serverFns/story/story-crud/labels.ts +3 -5
- package/src/server/serverFns/story/story-crud.ts +32 -2
- package/src/server/serverFns/story/story-dependencies.ts +123 -2
- package/src/server/serverFns/story/story-links.ts +30 -6
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/types.ts +1 -0
- package/src/types/project.ts +5 -5
- package/src/components/project/project-animated-icon.tsx +0 -73
- package/src/components/shared/icon-packs/index.ts +0 -1
- package/src/components/shared/icon-packs/knowledge.ts +0 -1
- package/src/components/shared/icon-packs/prd.ts +0 -1
- package/src/components/shared/icon-packs/project.ts +0 -1
- package/src/components/shared/icon-picker.tsx +0 -1
- 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 {
|
|
32
|
-
|
|
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
|
|
152
|
+
<div className="flex items-center gap-3">
|
|
153
153
|
<Label>{t('settings.projectIcon')}</Label>
|
|
154
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 {
|
|
30
|
-
|
|
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
|
|
124
|
+
<div className="flex items-center gap-3">
|
|
125
125
|
<Label>{t("settings.icon")}</Label>
|
|
126
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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>
|