Skip to content

Commit 6cec799

Browse files
authored
feat(Organization/Project Management): Edit/Delete Funcationality (#230)
1 parent 341e5f4 commit 6cec799

26 files changed

Lines changed: 1179 additions & 200 deletions

‎app/(main)/settings/onboarding/page.tsx‎

Lines changed: 212 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
11
"use client";
22

3-
import { useState, useEffect, useCallback } from "react";
3+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44
import SettingsSidebar from "@/app/components/settings/SettingsSidebar";
55
import PageHeader from "@/app/components/PageHeader";
66
import { useAuth } from "@/app/lib/context/AuthContext";
7-
import { usePaginatedList, useInfiniteScroll } from "@/app/hooks";
87
import {
8+
useDebouncedValue,
9+
useInfiniteScroll,
10+
useOnboardingActivation,
11+
usePaginatedList,
12+
} from "@/app/hooks";
13+
import {
14+
DeleteOrganizationModal,
15+
DeleteProjectModal,
16+
EditOrganizationModal,
17+
OnboardingCredentials,
918
OnboardingForm,
1019
OnboardingSuccess,
1120
OrganizationList,
21+
OrganizationListSkeleton,
1222
ProjectList,
1323
StepIndicator,
1424
UserList,
15-
OnboardingCredentials,
1625
} from "@/app/components/settings/onboarding";
26+
import { useToast } from "@/app/hooks/useToast";
1727
import {
28+
ActiveStatus,
1829
Organization,
1930
Project,
2031
ProjectListResponse,
@@ -25,41 +36,15 @@ import { ArrowLeftIcon } from "@/app/components/icons";
2536
import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants";
2637
import { TabNavigation } from "@/app/components/ui";
2738

39+
const SEARCH_DEBOUNCE_MS = 300;
40+
2841
const PROJECT_TABS = [
2942
{ id: "users", label: "Users" },
3043
{ id: "credentials", label: "Credentials" },
3144
];
3245

3346
type View = "loading" | "list" | "projects" | "users" | "form" | "success";
3447

35-
function OrganizationListSkeleton() {
36-
return (
37-
<div className="animate-pulse">
38-
<div className="flex items-center justify-between mb-4">
39-
<div>
40-
<div className="h-5 w-36 bg-neutral-200 rounded mb-2" />
41-
<div className="h-3 w-24 bg-neutral-100 rounded" />
42-
</div>
43-
<div className="h-9 w-40 bg-neutral-200 rounded-full" />
44-
</div>
45-
<div className="space-y-2.5">
46-
{[1, 2, 3].map((i) => (
47-
<div
48-
key={i}
49-
className="flex items-center justify-between gap-3 p-4 rounded-lg bg-bg-primary shadow-[0_2px_6px_rgba(0,0,0,0.06),0_1px_2px_rgba(0,0,0,0.04)]"
50-
>
51-
<div className="min-w-0 flex-1">
52-
<div className="h-4 w-40 max-w-full bg-neutral-200 rounded mb-2" />
53-
<div className="h-3 w-28 bg-neutral-100 rounded" />
54-
</div>
55-
<div className="h-4 w-4 shrink-0 bg-neutral-100 rounded" />
56-
</div>
57-
))}
58-
</div>
59-
</div>
60-
);
61-
}
62-
6348
export default function OnboardingPage() {
6449
const { activeKey } = useAuth();
6550
const [view, setView] = useState<View>("loading");
@@ -71,6 +56,34 @@ export default function OnboardingPage() {
7156
null,
7257
);
7358
const [activeProjectTab, setActiveProjectTab] = useState("users");
59+
const [orgToDelete, setOrgToDelete] = useState<Organization | null>(null);
60+
const [isDeletingOrg, setIsDeletingOrg] = useState(false);
61+
const [orgToEdit, setOrgToEdit] = useState<Organization | null>(null);
62+
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
63+
const [isDeletingProject, setIsDeletingProject] = useState(false);
64+
const [orgSearchInput, setOrgSearchInput] = useState("");
65+
const [projectSearchInput, setProjectSearchInput] = useState("");
66+
const [orgActiveStatus, setOrgActiveStatus] =
67+
useState<ActiveStatus>("active");
68+
const [projectActiveStatus, setProjectActiveStatus] =
69+
useState<ActiveStatus>("active");
70+
const toast = useToast();
71+
const debouncedOrgSearch = useDebouncedValue(
72+
orgSearchInput.trim(),
73+
SEARCH_DEBOUNCE_MS,
74+
);
75+
const debouncedProjectSearch = useDebouncedValue(
76+
projectSearchInput.trim(),
77+
SEARCH_DEBOUNCE_MS,
78+
);
79+
80+
const orgExtraParams = useMemo(() => {
81+
const params: Record<string, string> = {
82+
is_active: orgActiveStatus === "active" ? "true" : "false",
83+
};
84+
if (debouncedOrgSearch) params.search = debouncedOrgSearch;
85+
return params;
86+
}, [debouncedOrgSearch, orgActiveStatus]);
7487

7588
const {
7689
items: organizations,
@@ -82,6 +95,7 @@ export default function OnboardingPage() {
8295
} = usePaginatedList<Organization>({
8396
endpoint: "/api/organization",
8497
limit: DEFAULT_PAGE_LIMIT,
98+
extraParams: orgExtraParams,
8599
});
86100

87101
const scrollRef = useInfiniteScroll({
@@ -90,46 +104,81 @@ export default function OnboardingPage() {
90104
isLoading: isLoadingOrgs || isLoadingMore,
91105
});
92106

107+
const initialOrgViewDecidedRef = useRef(false);
93108
useEffect(() => {
94-
if (isLoadingOrgs) {
95-
setView("loading");
96-
return;
97-
}
98-
if (view === "loading") {
99-
setView(organizations.length > 0 ? "list" : "form");
100-
}
109+
if (initialOrgViewDecidedRef.current) return;
110+
if (isLoadingOrgs) return;
111+
initialOrgViewDecidedRef.current = true;
112+
setView(organizations.length > 0 ? "list" : "form");
101113
}, [isLoadingOrgs, organizations.length]);
102114

103-
const fetchProjects = useCallback(
104-
async (org: Organization) => {
105-
setSelectedOrg(org);
106-
setView("projects");
107-
setIsLoadingProjects(true);
108-
setProjects([]);
115+
const buildProjectsQuery = (search: string, status: ActiveStatus) => {
116+
const params = new URLSearchParams({
117+
is_active: status === "active" ? "true" : "false",
118+
});
119+
if (search) params.set("search", search);
120+
return params.toString();
121+
};
109122

123+
const loadProjects = useCallback(
124+
async (orgId: number, search: string, status: ActiveStatus) => {
125+
setIsLoadingProjects(true);
110126
try {
127+
const qs = buildProjectsQuery(search, status);
111128
const result = await apiFetch<ProjectListResponse>(
112-
`/api/organization/${org.id}/projects`,
129+
`/api/organization/${orgId}/projects?${qs}`,
113130
activeKey?.key ?? "",
114131
);
115-
116132
if (result.success && result.data) {
117133
setProjects(result.data);
134+
} else {
135+
setProjects([]);
118136
}
119137
} catch {
120-
// keep empty list
138+
setProjects([]);
121139
} finally {
122140
setIsLoadingProjects(false);
123141
}
124142
},
125143
[activeKey],
126144
);
127145

146+
const fetchProjects = useCallback(
147+
async (org: Organization) => {
148+
setSelectedOrg(org);
149+
setView("projects");
150+
setProjectSearchInput("");
151+
setProjectActiveStatus("active");
152+
setProjects([]);
153+
await loadProjects(org.id, "", "active");
154+
},
155+
[loadProjects],
156+
);
157+
158+
useEffect(() => {
159+
if (view !== "projects" || !selectedOrg) return;
160+
void loadProjects(
161+
selectedOrg.id,
162+
debouncedProjectSearch,
163+
projectActiveStatus,
164+
);
165+
}, [
166+
debouncedProjectSearch,
167+
projectActiveStatus,
168+
view,
169+
selectedOrg,
170+
loadProjects,
171+
]);
172+
128173
const refreshProjects = useCallback(async () => {
129174
if (!selectedOrg) return;
130175
try {
176+
const qs = buildProjectsQuery(
177+
debouncedProjectSearch,
178+
projectActiveStatus,
179+
);
131180
const result = await apiFetch<ProjectListResponse>(
132-
`/api/organization/${selectedOrg.id}/projects`,
181+
`/api/organization/${selectedOrg.id}/projects?${qs}`,
133182
activeKey?.key ?? "",
134183
);
135184
if (result.success && result.data) {
@@ -138,7 +187,7 @@ export default function OnboardingPage() {
138187
} catch {
139188
// keep current list
140189
}
141-
}, [selectedOrg, activeKey]);
190+
}, [selectedOrg, activeKey, debouncedProjectSearch, projectActiveStatus]);
142191

143192
const handleSuccess = (data: OnboardResponseData) => {
144193
setOnboardData(data);
@@ -165,6 +214,7 @@ export default function OnboardingPage() {
165214
setSelectedOrg(null);
166215
setSelectedProject(null);
167216
setProjects([]);
217+
setProjectSearchInput("");
168218
setView("list");
169219
};
170220

@@ -173,6 +223,71 @@ export default function OnboardingPage() {
173223
setView("projects");
174224
};
175225

226+
const handleConfirmDeleteOrg = async (hardDelete: boolean) => {
227+
if (!orgToDelete) return;
228+
setIsDeletingOrg(true);
229+
try {
230+
await apiFetch(
231+
`/api/organization/${orgToDelete.id}`,
232+
activeKey?.key ?? "",
233+
{
234+
method: "DELETE",
235+
body: JSON.stringify({ hard_delete: hardDelete }),
236+
},
237+
);
238+
toast.success(
239+
hardDelete
240+
? `"${orgToDelete.name}" permanently deleted`
241+
: `"${orgToDelete.name}" deactivated`,
242+
);
243+
setOrgToDelete(null);
244+
refetchOrganizations();
245+
} catch (e) {
246+
toast.error(
247+
e instanceof Error ? e.message : "Failed to delete organization",
248+
);
249+
} finally {
250+
setIsDeletingOrg(false);
251+
}
252+
};
253+
254+
const handleConfirmDeleteProject = async (hardDelete: boolean) => {
255+
if (!projectToDelete) return;
256+
setIsDeletingProject(true);
257+
try {
258+
await apiFetch(
259+
`/api/project/${projectToDelete.id}`,
260+
activeKey?.key ?? "",
261+
{
262+
method: "DELETE",
263+
body: JSON.stringify({ hard_delete: hardDelete }),
264+
},
265+
);
266+
toast.success(
267+
hardDelete
268+
? `"${projectToDelete.name}" permanently deleted`
269+
: `"${projectToDelete.name}" deactivated`,
270+
);
271+
setProjectToDelete(null);
272+
await refreshProjects();
273+
} catch (e) {
274+
toast.error(e instanceof Error ? e.message : "Failed to delete project");
275+
} finally {
276+
setIsDeletingProject(false);
277+
}
278+
};
279+
280+
const {
281+
activatingOrgId,
282+
activatingProjectId,
283+
activateOrg: handleActivateOrg,
284+
activateProject: handleActivateProject,
285+
} = useOnboardingActivation({
286+
apiKey: activeKey?.key ?? "",
287+
onOrgActivated: refetchOrganizations,
288+
onProjectActivated: refreshProjects,
289+
});
290+
176291
return (
177292
<div className="w-full h-screen flex flex-col bg-bg-primary">
178293
<div className="flex flex-1 overflow-hidden">
@@ -191,9 +306,18 @@ export default function OnboardingPage() {
191306
{view === "list" && (
192307
<OrganizationList
193308
organizations={organizations}
309+
isLoading={isLoadingOrgs}
194310
isLoadingMore={isLoadingMore}
195311
onNewOrg={() => setView("form")}
196312
onSelectOrg={fetchProjects}
313+
onDeleteOrg={setOrgToDelete}
314+
onEditOrg={setOrgToEdit}
315+
onActivateOrg={handleActivateOrg}
316+
activatingOrgId={activatingOrgId}
317+
search={orgSearchInput}
318+
onSearchChange={setOrgSearchInput}
319+
activeStatus={orgActiveStatus}
320+
onActiveStatusChange={setOrgActiveStatus}
197321
/>
198322
)}
199323

@@ -205,6 +329,13 @@ export default function OnboardingPage() {
205329
onBack={handleBackToOrgs}
206330
onSelectProject={handleSelectProject}
207331
onProjectAdded={refreshProjects}
332+
search={projectSearchInput}
333+
onSearchChange={setProjectSearchInput}
334+
activeStatus={projectActiveStatus}
335+
onActiveStatusChange={setProjectActiveStatus}
336+
onDeleteProject={setProjectToDelete}
337+
onActivateProject={handleActivateProject}
338+
activatingProjectId={activatingProjectId}
208339
/>
209340
)}
210341

@@ -313,6 +444,38 @@ export default function OnboardingPage() {
313444
</div>
314445
</div>
315446
</div>
447+
448+
{orgToDelete && (
449+
<DeleteOrganizationModal
450+
organization={orgToDelete}
451+
isDeleting={isDeletingOrg}
452+
onCancel={() => {
453+
if (!isDeletingOrg) setOrgToDelete(null);
454+
}}
455+
onConfirm={handleConfirmDeleteOrg}
456+
/>
457+
)}
458+
459+
{projectToDelete && (
460+
<DeleteProjectModal
461+
project={projectToDelete}
462+
isDeleting={isDeletingProject}
463+
onCancel={() => {
464+
if (!isDeletingProject) setProjectToDelete(null);
465+
}}
466+
onConfirm={handleConfirmDeleteProject}
467+
/>
468+
)}
469+
470+
{orgToEdit && (
471+
<EditOrganizationModal
472+
open
473+
organization={orgToEdit}
474+
apiKey={activeKey?.key ?? ""}
475+
onClose={() => setOrgToEdit(null)}
476+
onOrganizationUpdated={refetchOrganizations}
477+
/>
478+
)}
316479
</div>
317480
);
318481
}

‎app/api/organization/[orgId]/projects/route.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ export async function GET(
77
) {
88
try {
99
const { orgId } = await params;
10-
const { status, data } = await apiClient(
11-
request,
12-
`/api/v1/projects/organization/${orgId}`,
13-
);
10+
const { searchParams } = new URL(request.url);
11+
const qs = searchParams.toString();
12+
const endpoint = `/api/v1/projects/organization/${orgId}${qs ? `?${qs}` : ""}`;
13+
const { status, data } = await apiClient(request, endpoint);
1414
return NextResponse.json(data, { status });
1515
} catch {
1616
return NextResponse.json(

0 commit comments

Comments
 (0)