From 6740291c4f1d9bf39add635120a90e34064fa950 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Mon, 18 May 2026 12:28:16 +0000 Subject: [PATCH] #598 global "other apps" bottom dock + remove duplicate #596 gear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- artifacts/tx-os/src/components/app-dock.tsx | 84 +++++++++++++++++++ artifacts/tx-os/src/pages/admin.tsx | 2 + .../tx-os/src/pages/executive-meetings.tsx | 48 +++-------- artifacts/tx-os/src/pages/notes.tsx | 2 + artifacts/tx-os/src/pages/orders-incoming.tsx | 2 + artifacts/tx-os/src/pages/services.tsx | 2 + 6 files changed, 103 insertions(+), 37 deletions(-) create mode 100644 artifacts/tx-os/src/components/app-dock.tsx 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} /> + ); }