diff --git a/app/(main)/settings/onboarding/page.tsx b/app/(main)/settings/onboarding/page.tsx index 256a6a39..6ce0f6ce 100644 --- a/app/(main)/settings/onboarding/page.tsx +++ b/app/(main)/settings/onboarding/page.tsx @@ -1,20 +1,31 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import SettingsSidebar from "@/app/components/settings/SettingsSidebar"; import PageHeader from "@/app/components/PageHeader"; import { useAuth } from "@/app/lib/context/AuthContext"; -import { usePaginatedList, useInfiniteScroll } from "@/app/hooks"; import { + useDebouncedValue, + useInfiniteScroll, + useOnboardingActivation, + usePaginatedList, +} from "@/app/hooks"; +import { + DeleteOrganizationModal, + DeleteProjectModal, + EditOrganizationModal, + OnboardingCredentials, OnboardingForm, OnboardingSuccess, OrganizationList, + OrganizationListSkeleton, ProjectList, StepIndicator, UserList, - OnboardingCredentials, } from "@/app/components/settings/onboarding"; +import { useToast } from "@/app/hooks/useToast"; import { + ActiveStatus, Organization, Project, ProjectListResponse, @@ -25,6 +36,8 @@ import { ArrowLeftIcon } from "@/app/components/icons"; import { DEFAULT_PAGE_LIMIT } from "@/app/lib/constants"; import { TabNavigation } from "@/app/components/ui"; +const SEARCH_DEBOUNCE_MS = 300; + const PROJECT_TABS = [ { id: "users", label: "Users" }, { id: "credentials", label: "Credentials" }, @@ -32,34 +45,6 @@ const PROJECT_TABS = [ type View = "loading" | "list" | "projects" | "users" | "form" | "success"; -function OrganizationListSkeleton() { - return ( -
-
-
-
-
-
-
-
-
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
-
- ))} -
-
- ); -} - export default function OnboardingPage() { const { activeKey } = useAuth(); const [view, setView] = useState("loading"); @@ -71,6 +56,34 @@ export default function OnboardingPage() { null, ); const [activeProjectTab, setActiveProjectTab] = useState("users"); + const [orgToDelete, setOrgToDelete] = useState(null); + const [isDeletingOrg, setIsDeletingOrg] = useState(false); + const [orgToEdit, setOrgToEdit] = useState(null); + const [projectToDelete, setProjectToDelete] = useState(null); + const [isDeletingProject, setIsDeletingProject] = useState(false); + const [orgSearchInput, setOrgSearchInput] = useState(""); + const [projectSearchInput, setProjectSearchInput] = useState(""); + const [orgActiveStatus, setOrgActiveStatus] = + useState("active"); + const [projectActiveStatus, setProjectActiveStatus] = + useState("active"); + const toast = useToast(); + const debouncedOrgSearch = useDebouncedValue( + orgSearchInput.trim(), + SEARCH_DEBOUNCE_MS, + ); + const debouncedProjectSearch = useDebouncedValue( + projectSearchInput.trim(), + SEARCH_DEBOUNCE_MS, + ); + + const orgExtraParams = useMemo(() => { + const params: Record = { + is_active: orgActiveStatus === "active" ? "true" : "false", + }; + if (debouncedOrgSearch) params.search = debouncedOrgSearch; + return params; + }, [debouncedOrgSearch, orgActiveStatus]); const { items: organizations, @@ -82,6 +95,7 @@ export default function OnboardingPage() { } = usePaginatedList({ endpoint: "/api/organization", limit: DEFAULT_PAGE_LIMIT, + extraParams: orgExtraParams, }); const scrollRef = useInfiniteScroll({ @@ -90,34 +104,38 @@ export default function OnboardingPage() { isLoading: isLoadingOrgs || isLoadingMore, }); + const initialOrgViewDecidedRef = useRef(false); useEffect(() => { - if (isLoadingOrgs) { - setView("loading"); - return; - } - if (view === "loading") { - setView(organizations.length > 0 ? "list" : "form"); - } + if (initialOrgViewDecidedRef.current) return; + if (isLoadingOrgs) return; + initialOrgViewDecidedRef.current = true; + setView(organizations.length > 0 ? "list" : "form"); }, [isLoadingOrgs, organizations.length]); - const fetchProjects = useCallback( - async (org: Organization) => { - setSelectedOrg(org); - setView("projects"); - setIsLoadingProjects(true); - setProjects([]); + const buildProjectsQuery = (search: string, status: ActiveStatus) => { + const params = new URLSearchParams({ + is_active: status === "active" ? "true" : "false", + }); + if (search) params.set("search", search); + return params.toString(); + }; + const loadProjects = useCallback( + async (orgId: number, search: string, status: ActiveStatus) => { + setIsLoadingProjects(true); try { + const qs = buildProjectsQuery(search, status); const result = await apiFetch( - `/api/organization/${org.id}/projects`, + `/api/organization/${orgId}/projects?${qs}`, activeKey?.key ?? "", ); - if (result.success && result.data) { setProjects(result.data); + } else { + setProjects([]); } } catch { - // keep empty list + setProjects([]); } finally { setIsLoadingProjects(false); } @@ -125,11 +143,42 @@ export default function OnboardingPage() { [activeKey], ); + const fetchProjects = useCallback( + async (org: Organization) => { + setSelectedOrg(org); + setView("projects"); + setProjectSearchInput(""); + setProjectActiveStatus("active"); + setProjects([]); + await loadProjects(org.id, "", "active"); + }, + [loadProjects], + ); + + useEffect(() => { + if (view !== "projects" || !selectedOrg) return; + void loadProjects( + selectedOrg.id, + debouncedProjectSearch, + projectActiveStatus, + ); + }, [ + debouncedProjectSearch, + projectActiveStatus, + view, + selectedOrg, + loadProjects, + ]); + const refreshProjects = useCallback(async () => { if (!selectedOrg) return; try { + const qs = buildProjectsQuery( + debouncedProjectSearch, + projectActiveStatus, + ); const result = await apiFetch( - `/api/organization/${selectedOrg.id}/projects`, + `/api/organization/${selectedOrg.id}/projects?${qs}`, activeKey?.key ?? "", ); if (result.success && result.data) { @@ -138,7 +187,7 @@ export default function OnboardingPage() { } catch { // keep current list } - }, [selectedOrg, activeKey]); + }, [selectedOrg, activeKey, debouncedProjectSearch, projectActiveStatus]); const handleSuccess = (data: OnboardResponseData) => { setOnboardData(data); @@ -165,6 +214,7 @@ export default function OnboardingPage() { setSelectedOrg(null); setSelectedProject(null); setProjects([]); + setProjectSearchInput(""); setView("list"); }; @@ -173,6 +223,71 @@ export default function OnboardingPage() { setView("projects"); }; + const handleConfirmDeleteOrg = async (hardDelete: boolean) => { + if (!orgToDelete) return; + setIsDeletingOrg(true); + try { + await apiFetch( + `/api/organization/${orgToDelete.id}`, + activeKey?.key ?? "", + { + method: "DELETE", + body: JSON.stringify({ hard_delete: hardDelete }), + }, + ); + toast.success( + hardDelete + ? `"${orgToDelete.name}" permanently deleted` + : `"${orgToDelete.name}" deactivated`, + ); + setOrgToDelete(null); + refetchOrganizations(); + } catch (e) { + toast.error( + e instanceof Error ? e.message : "Failed to delete organization", + ); + } finally { + setIsDeletingOrg(false); + } + }; + + const handleConfirmDeleteProject = async (hardDelete: boolean) => { + if (!projectToDelete) return; + setIsDeletingProject(true); + try { + await apiFetch( + `/api/project/${projectToDelete.id}`, + activeKey?.key ?? "", + { + method: "DELETE", + body: JSON.stringify({ hard_delete: hardDelete }), + }, + ); + toast.success( + hardDelete + ? `"${projectToDelete.name}" permanently deleted` + : `"${projectToDelete.name}" deactivated`, + ); + setProjectToDelete(null); + await refreshProjects(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to delete project"); + } finally { + setIsDeletingProject(false); + } + }; + + const { + activatingOrgId, + activatingProjectId, + activateOrg: handleActivateOrg, + activateProject: handleActivateProject, + } = useOnboardingActivation({ + apiKey: activeKey?.key ?? "", + onOrgActivated: refetchOrganizations, + onProjectActivated: refreshProjects, + }); + return (
@@ -191,9 +306,18 @@ export default function OnboardingPage() { {view === "list" && ( setView("form")} onSelectOrg={fetchProjects} + onDeleteOrg={setOrgToDelete} + onEditOrg={setOrgToEdit} + onActivateOrg={handleActivateOrg} + activatingOrgId={activatingOrgId} + search={orgSearchInput} + onSearchChange={setOrgSearchInput} + activeStatus={orgActiveStatus} + onActiveStatusChange={setOrgActiveStatus} /> )} @@ -205,6 +329,13 @@ export default function OnboardingPage() { onBack={handleBackToOrgs} onSelectProject={handleSelectProject} onProjectAdded={refreshProjects} + search={projectSearchInput} + onSearchChange={setProjectSearchInput} + activeStatus={projectActiveStatus} + onActiveStatusChange={setProjectActiveStatus} + onDeleteProject={setProjectToDelete} + onActivateProject={handleActivateProject} + activatingProjectId={activatingProjectId} /> )} @@ -313,6 +444,38 @@ export default function OnboardingPage() {
+ + {orgToDelete && ( + { + if (!isDeletingOrg) setOrgToDelete(null); + }} + onConfirm={handleConfirmDeleteOrg} + /> + )} + + {projectToDelete && ( + { + if (!isDeletingProject) setProjectToDelete(null); + }} + onConfirm={handleConfirmDeleteProject} + /> + )} + + {orgToEdit && ( + setOrgToEdit(null)} + onOrganizationUpdated={refetchOrganizations} + /> + )}
); } diff --git a/app/api/organization/[orgId]/projects/route.ts b/app/api/organization/[orgId]/projects/route.ts index d3c01482..2bef2d99 100644 --- a/app/api/organization/[orgId]/projects/route.ts +++ b/app/api/organization/[orgId]/projects/route.ts @@ -7,10 +7,10 @@ export async function GET( ) { try { const { orgId } = await params; - const { status, data } = await apiClient( - request, - `/api/v1/projects/organization/${orgId}`, - ); + const { searchParams } = new URL(request.url); + const qs = searchParams.toString(); + const endpoint = `/api/v1/projects/organization/${orgId}${qs ? `?${qs}` : ""}`; + const { status, data } = await apiClient(request, endpoint); return NextResponse.json(data, { status }); } catch { return NextResponse.json( diff --git a/app/api/organization/[orgId]/route.ts b/app/api/organization/[orgId]/route.ts new file mode 100644 index 00000000..14851462 --- /dev/null +++ b/app/api/organization/[orgId]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + try { + const body = await request.json().catch(() => ({})); + const { status, data } = await apiClient( + request, + `/api/v1/organizations/${orgId}`, + { + method: "PATCH", + body: JSON.stringify(body), + }, + ); + return NextResponse.json(data, { status }); + } catch (error: unknown) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + try { + const body = await request.json().catch(() => ({})); + const { status, data } = await apiClient( + request, + `/api/v1/organizations/${orgId}`, + { + method: "DELETE", + body: JSON.stringify(body), + }, + ); + return NextResponse.json(data, { status }); + } catch (error: unknown) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} diff --git a/app/api/project/[projectId]/route.ts b/app/api/project/[projectId]/route.ts new file mode 100644 index 00000000..25b59afc --- /dev/null +++ b/app/api/project/[projectId]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { apiClient } from "@/app/lib/apiClient"; + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> }, +) { + const { projectId } = await params; + try { + const body = await request.json().catch(() => ({})); + const { status, data } = await apiClient( + request, + `/api/v1/projects/${projectId}`, + { + method: "DELETE", + body: JSON.stringify(body), + }, + ); + return NextResponse.json(data, { status }); + } catch (error: unknown) { + return NextResponse.json( + { + success: false, + error: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } +} diff --git a/app/components/guardrails/BanListModal.tsx b/app/components/guardrails/BanListModal.tsx index a0813949..24b03be0 100644 --- a/app/components/guardrails/BanListModal.tsx +++ b/app/components/guardrails/BanListModal.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { guardrailsFetch } from "@/app/lib/guardrailsClient"; import { useAuth } from "@/app/lib/context/AuthContext"; -import { Button, Field, Modal } from "@/app/components/ui"; +import { Button, Checkbox, Field, Modal } from "@/app/components/ui"; interface BanListModalProps { onClose: () => void; @@ -132,17 +132,11 @@ export default function BanListModal({ placeholder="e.g. healthcare, finance" /> - +
diff --git a/app/components/guardrails/ValidatorConfigPanel.tsx b/app/components/guardrails/ValidatorConfigPanel.tsx index 84d374cc..78805397 100644 --- a/app/components/guardrails/ValidatorConfigPanel.tsx +++ b/app/components/guardrails/ValidatorConfigPanel.tsx @@ -2,11 +2,12 @@ import { useEffect, useState } from "react"; import { Validator } from "@/app/lib/types/guardrails"; import { Button, + Checkbox, Field, InfoTooltip, - Select, - MultiSelect, Loader, + MultiSelect, + Select, } from "@/app/components/ui"; import { CloseIcon } from "@/app/components/icons"; import { @@ -323,15 +324,11 @@ export default function ValidatorConfigPanel({ />
- + {!readOnly && ( diff --git a/app/components/knowledge-base/DocumentPickerModal.tsx b/app/components/knowledge-base/DocumentPickerModal.tsx index 42f67373..6122fc22 100644 --- a/app/components/knowledge-base/DocumentPickerModal.tsx +++ b/app/components/knowledge-base/DocumentPickerModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo } from "react"; -import { Button, Modal } from "@/app/components/ui"; +import { Button, Checkbox, Modal } from "@/app/components/ui"; import { CheckLineIcon, SearchIcon } from "@/app/components/icons"; import { Document } from "@/app/lib/types/document"; @@ -70,11 +70,10 @@ export default function DocumentPickerModal({ : "border-border hover:bg-neutral-50" }`} > - onToggleDocument(doc.id)} - className="mr-3 w-4 h-4 cursor-pointer accent-accent-primary" + inputClassName="mr-3 cursor-pointer" />

) : (

- + {provider.fields.map((field) => (
- + {provider.fields.map((field) => ( -
- -

- Inactive projects won't be available for use. -

-
+
+ +
+ + ); +} diff --git a/app/components/settings/onboarding/DeleteProjectModal.tsx b/app/components/settings/onboarding/DeleteProjectModal.tsx new file mode 100644 index 00000000..8cab5321 --- /dev/null +++ b/app/components/settings/onboarding/DeleteProjectModal.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useState } from "react"; +import { Button, Checkbox, Field, Modal } from "@/app/components/ui"; +import { WarningIcon } from "@/app/components/icons"; +import { Project } from "@/app/lib/types/onboarding"; + +interface DeleteProjectModalProps { + project: Project; + isDeleting: boolean; + onCancel: () => void; + onConfirm: (hardDelete: boolean) => void; +} + +const HARD_DELETE_ITEMS = [ + "Collections and documents", + "Credentials and assistants", + "Fine-tunings and conversations", + "User-project mappings", +]; + +export default function DeleteProjectModal({ + project, + isDeleting, + onCancel, + onConfirm, +}: DeleteProjectModalProps) { + const [typedName, setTypedName] = useState(""); + const [hardDelete, setHardDelete] = useState(false); + const nameMatches = typedName.trim() === project.name; + const canDelete = nameMatches && !isDeleting; + + return ( + +
+
+
+ +
+
+

+ You're about to delete {project.name}. +

+ {!hardDelete && ( +

+ Soft delete — the project is marked inactive. No data is removed + and you can reactivate it later. +

+ )} +
+
+ + {hardDelete && ( +
+

+ Permanent delete — this cannot be undone. +

+

+ Everything under this project will be removed: +

+
    + {HARD_DELETE_ITEMS.map((item) => ( +
  • {item}
  • + ))} +
+
+ )} + + + +
+ + +
+
+ +
+ + +
+
+ ); +} diff --git a/app/components/settings/onboarding/EditOrganizationModal.tsx b/app/components/settings/onboarding/EditOrganizationModal.tsx new file mode 100644 index 00000000..0607501b --- /dev/null +++ b/app/components/settings/onboarding/EditOrganizationModal.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button, Checkbox, Field, Modal } from "@/app/components/ui"; +import { useToast } from "@/app/hooks/useToast"; +import { + EditOrganizationModalProps, + OrganizationResponse, +} from "@/app/lib/types/onboarding"; +import { apiFetch } from "@/app/lib/apiClient"; + +export default function EditOrganizationModal({ + open, + onClose, + organization, + apiKey, + onOrganizationUpdated, +}: EditOrganizationModalProps) { + const toast = useToast(); + const [name, setName] = useState(organization.name); + const [isActive, setIsActive] = useState(organization.is_active); + const [isSubmitting, setIsSubmitting] = useState(false); + const [nameError, setNameError] = useState(""); + + useEffect(() => { + setName(organization.name); + setIsActive(organization.is_active); + setNameError(""); + }, [organization]); + + const handleClose = () => { + if (isSubmitting) return; + setName(organization.name); + setIsActive(organization.is_active); + setNameError(""); + onClose(); + }; + + const handleSubmit = async () => { + if (!name.trim()) { + setNameError("Organization name is required"); + return; + } + + setNameError(""); + setIsSubmitting(true); + + try { + await apiFetch( + `/api/organization/${organization.id}`, + apiKey, + { + method: "PATCH", + body: JSON.stringify({ + name: name.trim(), + is_active: isActive, + }), + }, + ); + + toast.success("Organization updated"); + onOrganizationUpdated(); + onClose(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to update organization", + ); + } finally { + setIsSubmitting(false); + } + }; + + const hasChanges = + name.trim() !== organization.name || isActive !== organization.is_active; + + return ( + +
+ { + setName(val); + if (nameError) setNameError(""); + }} + placeholder="Enter organization name" + error={nameError} + /> + + + +
+ + +
+
+
+ ); +} diff --git a/app/components/settings/onboarding/EditProjectModal.tsx b/app/components/settings/onboarding/EditProjectModal.tsx index f239203b..339012d6 100644 --- a/app/components/settings/onboarding/EditProjectModal.tsx +++ b/app/components/settings/onboarding/EditProjectModal.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { Button, Field, Modal } from "@/app/components/ui"; +import { Button, Checkbox, Field, Modal } from "@/app/components/ui"; import { useToast } from "@/app/hooks/useToast"; import { EditProjectModalProps, @@ -18,21 +18,21 @@ export default function EditProjectModal({ }: EditProjectModalProps) { const toast = useToast(); const [name, setName] = useState(project.name); - const [description, setDescription] = useState(project.description); + const [description, setDescription] = useState(project.description ?? ""); const [isActive, setIsActive] = useState(project.is_active); const [isSubmitting, setIsSubmitting] = useState(false); const [nameError, setNameError] = useState(""); useEffect(() => { setName(project.name); - setDescription(project.description); + setDescription(project.description ?? ""); setIsActive(project.is_active); setNameError(""); }, [project]); const handleClose = () => { setName(project.name); - setDescription(project.description); + setDescription(project.description ?? ""); setIsActive(project.is_active); setNameError(""); onClose(); @@ -95,20 +95,12 @@ export default function EditProjectModal({ placeholder="Enter project description (optional)" /> -
- -

- Inactive projects won't be available for use. -

-
+
+ )} +
+
+ + onSearchChange(e.target.value)} + placeholder="Search organizations..." + className="w-full pl-10 pr-4 py-2.5 rounded-full bg-bg-secondary text-text-primary text-sm placeholder:text-neutral focus:outline-none focus:ring-1 focus:ring-accent-primary focus:bg-bg-primary transition-colors" + /> +
+
+ onActiveStatusChange(id as ActiveStatus)} + />
- {currentUser?.is_superuser && ( - - )}
-
- {organizations.map((org) => ( - +
+ {canActivate && !org.is_active && ( + + )} + {canEdit && ( + + )} + {canDelete && ( + + )} + +
- - - ))} + )) + )}
{isLoadingMore && ( diff --git a/app/components/settings/onboarding/OrganizationListSkeleton.tsx b/app/components/settings/onboarding/OrganizationListSkeleton.tsx new file mode 100644 index 00000000..b2cf0623 --- /dev/null +++ b/app/components/settings/onboarding/OrganizationListSkeleton.tsx @@ -0,0 +1,27 @@ +export default function OrganizationListSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/app/components/settings/onboarding/ProjectList.tsx b/app/components/settings/onboarding/ProjectList.tsx index c5cc0d50..08b27d02 100644 --- a/app/components/settings/onboarding/ProjectList.tsx +++ b/app/components/settings/onboarding/ProjectList.tsx @@ -1,17 +1,24 @@ "use client"; import { useState } from "react"; -import { Project, ProjectListProps } from "@/app/lib/types/onboarding"; +import { + ActiveStatus, + Project, + ProjectListProps, +} from "@/app/lib/types/onboarding"; import { formatRelativeTime } from "@/app/lib/utils"; import { ArrowLeftIcon, ChevronRightIcon, EditIcon, + SearchIcon, + TrashIcon, } from "@/app/components/icons"; -import { Button } from "@/app/components/ui"; +import { Button, TabNavigation } from "@/app/components/ui"; import { useAuth } from "@/app/lib/context/AuthContext"; import AddProjectModal from "./AddProjectModal"; import EditProjectModal from "./EditProjectModal"; +import { STATUS_TABS } from "@/app/lib/constants"; function ProjectListSkeleton() { return ( @@ -42,43 +49,77 @@ export default function ProjectList({ onBack, onSelectProject, onProjectAdded, + search, + onSearchChange, + activeStatus, + onActiveStatusChange, + onDeleteProject, + onActivateProject, + activatingProjectId = null, }: ProjectListProps) { const { activeKey, currentUser } = useAuth(); + const canActivate = currentUser?.is_superuser && !!onActivateProject; const [showAddModal, setShowAddModal] = useState(false); const [editingProject, setEditingProject] = useState(null); return (
- +
+ + +
+
+

+ {organization.name} +

+

+ {isLoading + ? "Loading projects..." + : `${projects.length} project${projects.length !== 1 ? "s" : ""}`} +

+
+ {currentUser?.is_superuser && ( + + )} +
-
-
-

- {organization.name} -

-

- {isLoading - ? "Loading projects..." - : `${projects.length} project${projects.length !== 1 ? "s" : ""}`} -

+
+ + onSearchChange(e.target.value)} + placeholder="Search projects..." + className="w-full pl-10 pr-4 py-2.5 rounded-full bg-bg-secondary text-text-primary text-sm placeholder:text-neutral focus:outline-none focus:ring-1 focus:ring-accent-primary focus:bg-bg-primary transition-colors" + /> +
+
+ onActiveStatusChange(id as ActiveStatus)} + />
- {currentUser?.is_superuser && ( - - )}
+
+ {isLoading ? ( ) : projects.length === 0 ? (
- No projects found for this organization. + {search.trim() + ? `No ${activeStatus} projects match "${search.trim()}"` + : activeStatus === "inactive" + ? "No inactive projects in this organization." + : "No projects found for this organization."}
) : (
@@ -104,15 +145,22 @@ export default function ProjectList({

- - {project.is_active ? "Active" : "Inactive"} - + {canActivate && !project.is_active && ( + + )} {currentUser?.is_superuser && ( )} + {currentUser?.is_superuser && onDeleteProject && ( + + )}