feat(ui): company-prefix routes, archive company, hide archived from sidebar
Support optional company-prefix in URL paths (e.g. /PAP/issues/PAP-1). Filter archived companies from sidebar rail, switcher, and auto-select. Add archive button to company settings with confirmation dialog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,50 @@ function CloudAccessGate() {
|
|||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function boardRoutes() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Route index element={<Navigate to="dashboard" replace />} />
|
||||||
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="companies" element={<Companies />} />
|
||||||
|
<Route path="company/settings" element={<CompanySettings />} />
|
||||||
|
<Route path="org" element={<OrgChart />} />
|
||||||
|
<Route path="agents" element={<Navigate to="agents/all" replace />} />
|
||||||
|
<Route path="agents/all" element={<Agents />} />
|
||||||
|
<Route path="agents/active" element={<Agents />} />
|
||||||
|
<Route path="agents/paused" element={<Agents />} />
|
||||||
|
<Route path="agents/error" element={<Agents />} />
|
||||||
|
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||||
|
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
|
||||||
|
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
|
||||||
|
<Route path="projects" element={<Projects />} />
|
||||||
|
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
||||||
|
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
|
||||||
|
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
||||||
|
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
|
||||||
|
<Route path="issues" element={<Issues />} />
|
||||||
|
<Route path="issues/all" element={<Navigate to="issues" replace />} />
|
||||||
|
<Route path="issues/active" element={<Navigate to="issues" replace />} />
|
||||||
|
<Route path="issues/backlog" element={<Navigate to="issues" replace />} />
|
||||||
|
<Route path="issues/done" element={<Navigate to="issues" replace />} />
|
||||||
|
<Route path="issues/recent" element={<Navigate to="issues" replace />} />
|
||||||
|
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||||
|
<Route path="goals" element={<Goals />} />
|
||||||
|
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||||
|
<Route path="approvals" element={<Navigate to="approvals/pending" replace />} />
|
||||||
|
<Route path="approvals/pending" element={<Approvals />} />
|
||||||
|
<Route path="approvals/all" element={<Approvals />} />
|
||||||
|
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
||||||
|
<Route path="costs" element={<Costs />} />
|
||||||
|
<Route path="activity" element={<Activity />} />
|
||||||
|
<Route path="inbox" element={<Navigate to="inbox/new" replace />} />
|
||||||
|
<Route path="inbox/new" element={<Inbox />} />
|
||||||
|
<Route path="inbox/all" element={<Inbox />} />
|
||||||
|
<Route path="design-guide" element={<DesignGuide />} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -91,44 +135,13 @@ export function App() {
|
|||||||
<Route path="invite/:token" element={<InviteLandingPage />} />
|
<Route path="invite/:token" element={<InviteLandingPage />} />
|
||||||
|
|
||||||
<Route element={<CloudAccessGate />}>
|
<Route element={<CloudAccessGate />}>
|
||||||
|
{/* Company-prefixed routes: /PAP/issues/PAP-214 */}
|
||||||
|
<Route path=":companyPrefix" element={<Layout />}>
|
||||||
|
{boardRoutes()}
|
||||||
|
</Route>
|
||||||
|
{/* Non-prefixed routes: /issues/PAP-214 */}
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
{boardRoutes()}
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
|
||||||
<Route path="companies" element={<Companies />} />
|
|
||||||
<Route path="company/settings" element={<CompanySettings />} />
|
|
||||||
<Route path="org" element={<OrgChart />} />
|
|
||||||
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
|
|
||||||
<Route path="agents/all" element={<Agents />} />
|
|
||||||
<Route path="agents/active" element={<Agents />} />
|
|
||||||
<Route path="agents/paused" element={<Agents />} />
|
|
||||||
<Route path="agents/error" element={<Agents />} />
|
|
||||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
|
||||||
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
|
|
||||||
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
|
|
||||||
<Route path="projects" element={<Projects />} />
|
|
||||||
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
|
||||||
<Route path="projects/:projectId/overview" element={<ProjectDetail />} />
|
|
||||||
<Route path="projects/:projectId/issues" element={<ProjectDetail />} />
|
|
||||||
<Route path="projects/:projectId/issues/:filter" element={<ProjectDetail />} />
|
|
||||||
<Route path="issues" element={<Issues />} />
|
|
||||||
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
|
||||||
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
|
||||||
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
|
|
||||||
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
|
||||||
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
|
|
||||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
|
||||||
<Route path="goals" element={<Goals />} />
|
|
||||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
|
||||||
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
|
||||||
<Route path="approvals/pending" element={<Approvals />} />
|
|
||||||
<Route path="approvals/all" element={<Approvals />} />
|
|
||||||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
|
||||||
<Route path="costs" element={<Costs />} />
|
|
||||||
<Route path="activity" element={<Activity />} />
|
|
||||||
<Route path="inbox" element={<Navigate to="/inbox/new" replace />} />
|
|
||||||
<Route path="inbox/new" element={<Inbox />} />
|
|
||||||
<Route path="inbox/all" element={<Inbox />} />
|
|
||||||
<Route path="design-guide" element={<DesignGuide />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -135,19 +135,26 @@ function SortableCompanyItem({
|
|||||||
export function CompanyRail() {
|
export function CompanyRail() {
|
||||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||||
const { openOnboarding } = useDialog();
|
const { openOnboarding } = useDialog();
|
||||||
|
const sidebarCompanies = useMemo(
|
||||||
|
() => companies.filter((company) => company.status !== "archived"),
|
||||||
|
[companies],
|
||||||
|
);
|
||||||
|
|
||||||
// Maintain sorted order in local state, synced from companies + localStorage
|
// Maintain sorted order in local state, synced from companies + localStorage
|
||||||
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
|
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
|
||||||
sortByStoredOrder(companies).map((c) => c.id)
|
sortByStoredOrder(sidebarCompanies).map((c) => c.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Re-sync orderedIds from localStorage whenever companies changes.
|
// Re-sync orderedIds from localStorage whenever companies changes.
|
||||||
// Handles initial data load (companies starts as [] before query resolves)
|
// Handles initial data load (companies starts as [] before query resolves)
|
||||||
// and subsequent refetches triggered by live updates.
|
// and subsequent refetches triggered by live updates.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (companies.length === 0) return;
|
if (sidebarCompanies.length === 0) {
|
||||||
setOrderedIds(sortByStoredOrder(companies).map((c) => c.id));
|
setOrderedIds([]);
|
||||||
}, [companies]);
|
return;
|
||||||
|
}
|
||||||
|
setOrderedIds(sortByStoredOrder(sidebarCompanies).map((c) => c.id));
|
||||||
|
}, [sidebarCompanies]);
|
||||||
|
|
||||||
// Sync order across tabs via the native storage event
|
// Sync order across tabs via the native storage event
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -164,7 +171,7 @@ export function CompanyRail() {
|
|||||||
|
|
||||||
// Re-derive when companies change (new company added/removed)
|
// Re-derive when companies change (new company added/removed)
|
||||||
const orderedCompanies = useMemo(() => {
|
const orderedCompanies = useMemo(() => {
|
||||||
const byId = new Map(companies.map((c) => [c.id, c]));
|
const byId = new Map(sidebarCompanies.map((c) => [c.id, c]));
|
||||||
const result: Company[] = [];
|
const result: Company[] = [];
|
||||||
for (const id of orderedIds) {
|
for (const id of orderedIds) {
|
||||||
const c = byId.get(id);
|
const c = byId.get(id);
|
||||||
@@ -178,7 +185,7 @@ export function CompanyRail() {
|
|||||||
result.push(c);
|
result.push(c);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [companies, orderedIds]);
|
}, [sidebarCompanies, orderedIds]);
|
||||||
|
|
||||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function statusDotColor(status?: string): string {
|
|||||||
|
|
||||||
export function CompanySwitcher() {
|
export function CompanySwitcher() {
|
||||||
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
|
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
|
||||||
|
const sidebarCompanies = companies.filter((company) => company.status !== "archived");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -48,7 +49,7 @@ export function CompanySwitcher() {
|
|||||||
<DropdownMenuContent align="start" className="w-[220px]">
|
<DropdownMenuContent align="start" className="w-[220px]">
|
||||||
<DropdownMenuLabel>Companies</DropdownMenuLabel>
|
<DropdownMenuLabel>Companies</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{companies.map((company) => (
|
{sidebarCompanies.map((company) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={company.id}
|
key={company.id}
|
||||||
onClick={() => setSelectedCompanyId(company.id)}
|
onClick={() => setSelectedCompanyId(company.id)}
|
||||||
@@ -58,7 +59,7 @@ export function CompanySwitcher() {
|
|||||||
<span className="truncate">{company.name}</span>
|
<span className="truncate">{company.name}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
{companies.length === 0 && (
|
{sidebarCompanies.length === 0 && (
|
||||||
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
|
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -67,19 +67,24 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
|||||||
},
|
},
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
const sidebarCompanies = useMemo(
|
||||||
|
() => companies.filter((company) => company.status !== "archived"),
|
||||||
|
[companies],
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-select first company when list loads
|
// Auto-select first company when list loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (companies.length === 0) return;
|
if (companies.length === 0) return;
|
||||||
|
|
||||||
|
const selectableCompanies = sidebarCompanies.length > 0 ? sidebarCompanies : companies;
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored && companies.some((c) => c.id === stored)) return;
|
if (stored && selectableCompanies.some((c) => c.id === stored)) return;
|
||||||
if (selectedCompanyId && companies.some((c) => c.id === selectedCompanyId)) return;
|
if (selectedCompanyId && selectableCompanies.some((c) => c.id === selectedCompanyId)) return;
|
||||||
|
|
||||||
const next = companies[0]!.id;
|
const next = selectableCompanies[0]!.id;
|
||||||
setSelectedCompanyIdState(next);
|
setSelectedCompanyIdState(next);
|
||||||
localStorage.setItem(STORAGE_KEY, next);
|
localStorage.setItem(STORAGE_KEY, next);
|
||||||
}, [companies, selectedCompanyId]);
|
}, [companies, selectedCompanyId, sidebarCompanies]);
|
||||||
|
|
||||||
const setSelectedCompanyId = useCallback((companyId: string) => {
|
const setSelectedCompanyId = useCallback((companyId: string) => {
|
||||||
setSelectedCompanyIdState(companyId);
|
setSelectedCompanyIdState(companyId);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
|||||||
import { Field, ToggleField, HintIcon } from "../components/agent-config-primitives";
|
import { Field, ToggleField, HintIcon } from "../components/agent-config-primitives";
|
||||||
|
|
||||||
export function CompanySettings() {
|
export function CompanySettings() {
|
||||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
const { companies, selectedCompany, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -74,6 +74,22 @@ export function CompanySettings() {
|
|||||||
setInviteError(err instanceof Error ? err.message : "Failed to create invite");
|
setInviteError(err instanceof Error ? err.message : "Failed to create invite");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
companyId,
|
||||||
|
nextCompanyId,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
nextCompanyId: string | null;
|
||||||
|
}) => companiesApi.archive(companyId).then(() => ({ nextCompanyId })),
|
||||||
|
onSuccess: async ({ nextCompanyId }) => {
|
||||||
|
if (nextCompanyId) {
|
||||||
|
setSelectedCompanyId(nextCompanyId);
|
||||||
|
}
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
@@ -256,6 +272,48 @@ export function CompanySettings() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Archive */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-xs font-medium text-amber-700 uppercase tracking-wide">
|
||||||
|
Archive
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 rounded-md border border-amber-300/60 bg-amber-100/30 px-4 py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Archive this company to hide it from the sidebar. This persists in the database.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={archiveMutation.isPending || selectedCompany.status === "archived"}
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedCompanyId) return;
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Archive company "${selectedCompany.name}"? It will be hidden from the sidebar.`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
const nextCompanyId = companies.find((company) =>
|
||||||
|
company.id !== selectedCompanyId && company.status !== "archived")?.id ?? null;
|
||||||
|
archiveMutation.mutate({ companyId: selectedCompanyId, nextCompanyId });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{archiveMutation.isPending
|
||||||
|
? "Archiving..."
|
||||||
|
: selectedCompany.status === "archived"
|
||||||
|
? "Already archived"
|
||||||
|
: "Archive company"}
|
||||||
|
</Button>
|
||||||
|
{archiveMutation.isError && (
|
||||||
|
<span className="text-xs text-destructive">
|
||||||
|
{archiveMutation.error instanceof Error
|
||||||
|
? archiveMutation.error.message
|
||||||
|
: "Failed to archive company"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user