diff --git a/artifacts/tx-os/src/components/app-dock.tsx b/artifacts/tx-os/src/components/app-dock.tsx
new file mode 100644
index 00000000..0011b2c7
--- /dev/null
+++ b/artifacts/tx-os/src/components/app-dock.tsx
@@ -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 (
+
+
+ {others.map((app) => {
+ const Icon = resolveIcon(app.iconName);
+ const label = lang === "ar" ? app.nameAr : app.nameEn;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx
index adc30979..af73cdd1 100644
--- a/artifacts/tx-os/src/pages/admin.tsx
+++ b/artifacts/tx-os/src/pages/admin.tsx
@@ -146,6 +146,7 @@ function resolveAppIcon(name: string): LucideIcon | null {
return null;
}
import { Button } from "@/components/ui/button";
+import { AppDock } from "@/components/app-dock";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -2337,6 +2338,7 @@ export default function AdminPage() {
onClose={() => setDependencyTarget(null)}
/>
)}
+
);
}
diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx
index dc40454e..c24ff3db 100644
--- a/artifacts/tx-os/src/pages/executive-meetings.tsx
+++ b/artifacts/tx-os/src/pages/executive-meetings.tsx
@@ -48,6 +48,7 @@ import {
Minimize2,
} from "lucide-react";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
+import { AppDock } from "@/components/app-dock";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -1103,43 +1104,13 @@ function ExecutiveMeetingsPageInner() {
{t("executiveMeetings.exportPdf")}
)}
- {/* #596: President's only entry point to Settings (font
- family/size/etc). The full sub-nav stays hidden for
- them; a single gear button switches to the settings
- section, and a back arrow returns to the schedule. */}
- {isPresidentView && section === "schedule" && (
-
- )}
- {isPresidentView && section === "settings" && (
-
- )}
+ {/* #598: gear + back-arrow added in #596 removed — the
+ global bottom AppDock (mounted at page root) now
+ provides cross-app navigation, including the Admin /
+ Settings entry. The L902 effect that snaps section
+ back to "schedule" outside (schedule|settings) stays;
+ it still gates the hidden Manage / Audit tabs for the
+ president. */}
@@ -1285,6 +1256,9 @@ function ExecutiveMeetingsPageInner() {
/>
)}
+ {/* #598: global app switcher. Hidden in schedule-fullscreen so
+ the calendar can fill the screen unobstructed. */}
+ {!isFullscreen && }
);
}
diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx
index 02b20640..0ae30f95 100644
--- a/artifacts/tx-os/src/pages/notes.tsx
+++ b/artifacts/tx-os/src/pages/notes.tsx
@@ -125,6 +125,7 @@ import {
LabelMenu,
} from "@/components/notes/composer";
import { SendNoteDialog } from "@/components/notes/send-note-dialog";
+import { AppDock } from "@/components/app-dock";
import {
DndContext,
MouseSensor,
@@ -1497,6 +1498,7 @@ export default function NotesPage() {
}}
/>
)}
+
);
diff --git a/artifacts/tx-os/src/pages/orders-incoming.tsx b/artifacts/tx-os/src/pages/orders-incoming.tsx
index 8ae39619..3a7acc1e 100644
--- a/artifacts/tx-os/src/pages/orders-incoming.tsx
+++ b/artifacts/tx-os/src/pages/orders-incoming.tsx
@@ -41,6 +41,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { cn } from "@/lib/utils";
+import { AppDock } from "@/components/app-dock";
export const ORDER_RECEIVER_ROLE = "order_receiver";
@@ -731,6 +732,7 @@ export default function OrdersIncomingPage() {
+
);
}
diff --git a/artifacts/tx-os/src/pages/services.tsx b/artifacts/tx-os/src/pages/services.tsx
index 861aeabb..f7d46eb9 100644
--- a/artifacts/tx-os/src/pages/services.tsx
+++ b/artifacts/tx-os/src/pages/services.tsx
@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { resolveServiceImageUrl } from "@/lib/image-url";
import { OrderServiceModal } from "@/components/order-service-modal";
+import { AppDock } from "@/components/app-dock";
type CatalogService = {
id: number;
@@ -138,6 +139,7 @@ export default function ServicesPage() {
service={orderTarget}
resolveImage={resolveServiceImageUrl}
/>
+
);
}