#598 global "other apps" bottom dock + remove duplicate #596 gear

New shared component `artifacts/tx-os/src/components/app-dock.tsx`:
takes a `currentSlug` prop, pulls the apps list from the same
`useListApps()` query Home uses, filters out the current app and any
inactive ones, and renders a floating centered pill near the bottom
with one circular tinted button per remaining app. Reuses Home's
`resolveIcon(name)` pattern (Lucide string -> component, falls back to
Grid2X2) and Home's openApp logic (internal -> setLocation, external
_tab -> window.open, external_iframe -> /embedded/:id). Renders
nothing when fewer than 1 other apps are available. Hidden in print,
respects safe-area-inset-bottom, scrollable on narrow widths.

Mounted on every app page except Home:
- executive-meetings.tsx (currentSlug="executive-meetings", matches
  seed slug; hidden when isFullscreen is true so the schedule fills
  the screen unobstructed).
- services.tsx (currentSlug="services")
- notes.tsx (currentSlug="notes")
- orders-incoming.tsx (currentSlug="orders-incoming" — not a built-in
  app slug, so all apps show, which is the right UX since incoming
  inbox is reached via the topbar, not the home grid).
- admin.tsx (currentSlug="admin")

Removed the two gear/back-arrow blocks added by #596 in
executive-meetings.tsx (test ids em-president-settings,
em-president-back-to-schedule) — the dock now provides cross-app
navigation including Admin/Settings. The L902 force-schedule effect
that snaps the president back to the schedule section outside of
schedule|settings is left in place; it still gates the hidden
Manage / Audit tabs.

Verified `pnpm --filter @workspace/tx-os exec tsc --noEmit` clean.
No backend, no new translations, Home untouched.
This commit is contained in:
riyadhafraa
2026-05-18 12:28:16 +00:00
parent 5eeff7abaa
commit 264f52827d
6 changed files with 103 additions and 37 deletions
@@ -0,0 +1,84 @@
import { useLocation } from "wouter";
import { useTranslation } from "react-i18next";
import * as LucideIcons from "lucide-react";
import type { LucideIcon } from "lucide-react";
import {
useListApps,
getListAppsQueryKey,
logAppOpen,
type App,
} from "@workspace/api-client-react";
type IconName = keyof typeof LucideIcons;
function isIconName(x: string): x is IconName {
return x in LucideIcons;
}
function resolveIcon(name: string): LucideIcon {
if (isIconName(name)) {
const Candidate = LucideIcons[name] as unknown;
if (typeof Candidate === "function" || typeof Candidate === "object") {
return Candidate as LucideIcon;
}
}
return LucideIcons.Grid2X2;
}
export interface AppDockProps {
currentSlug: string;
}
export function AppDock({ currentSlug }: AppDockProps) {
const [, setLocation] = useLocation();
const { i18n } = useTranslation();
const lang = i18n.language;
const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } });
const others = (apps ?? []).filter(
(a) => a.isActive && a.slug !== currentSlug,
);
if (others.length < 1) return null;
const openApp = (app: App) => {
logAppOpen(app.id).catch(() => {});
const mode = app.openMode ?? "internal";
if (mode === "external_tab" && app.externalUrl) {
window.open(app.externalUrl, "_blank", "noopener,noreferrer");
return;
}
if (mode === "external_iframe" && app.externalUrl) {
setLocation(`/embedded/${app.id}`);
return;
}
setLocation(app.route);
};
return (
<div
className="fixed inset-x-0 bottom-2 sm:bottom-3 z-40 flex justify-center print:hidden pointer-events-none"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
data-testid="app-dock"
>
<div className="pointer-events-auto flex items-center gap-3 sm:gap-4 px-3 sm:px-4 py-2 rounded-full bg-white/95 backdrop-blur border border-gray-200 shadow-lg max-w-[min(92vw,640px)] overflow-x-auto no-scrollbar">
{others.map((app) => {
const Icon = resolveIcon(app.iconName);
const label = lang === "ar" ? app.nameAr : app.nameEn;
return (
<button
key={app.id}
type="button"
onClick={() => openApp(app)}
aria-label={label}
title={label}
data-testid={`app-dock-${app.slug}`}
className="shrink-0 w-11 h-11 rounded-full flex items-center justify-center hover:scale-110 transition-transform"
style={{ backgroundColor: `${app.color}25` }}
>
<Icon size={20} color={app.color} />
</button>
);
})}
</div>
</div>
);
}