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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -146,6 +146,7 @@ function resolveAppIcon(name: string): LucideIcon | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AppDock } from "@/components/app-dock";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -2337,6 +2338,7 @@ export default function AdminPage() {
|
|||||||
onClose={() => setDependencyTarget(null)}
|
onClose={() => setDependencyTarget(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<AppDock currentSlug="admin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
Minimize2,
|
Minimize2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||||
|
import { AppDock } from "@/components/app-dock";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -1103,43 +1104,13 @@ function ExecutiveMeetingsPageInner() {
|
|||||||
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
|
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* #596: President's only entry point to Settings (font
|
{/* #598: gear + back-arrow added in #596 removed — the
|
||||||
family/size/etc). The full sub-nav stays hidden for
|
global bottom AppDock (mounted at page root) now
|
||||||
them; a single gear button switches to the settings
|
provides cross-app navigation, including the Admin /
|
||||||
section, and a back arrow returns to the schedule. */}
|
Settings entry. The L902 effect that snaps section
|
||||||
{isPresidentView && section === "schedule" && (
|
back to "schedule" outside (schedule|settings) stays;
|
||||||
<Button
|
it still gates the hidden Manage / Audit tabs for the
|
||||||
variant="outline"
|
president. */}
|
||||||
size="sm"
|
|
||||||
className="w-9 px-0"
|
|
||||||
onClick={() => setSection("settings")}
|
|
||||||
aria-label={t("executiveMeetings.nav.settings")}
|
|
||||||
title={t("executiveMeetings.nav.settings")}
|
|
||||||
data-testid="em-president-settings"
|
|
||||||
>
|
|
||||||
<SettingsIcon className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isPresidentView && section === "settings" && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-1.5"
|
|
||||||
onClick={() => setSection("schedule")}
|
|
||||||
aria-label={t("executiveMeetings.nav.schedule")}
|
|
||||||
title={t("executiveMeetings.nav.schedule")}
|
|
||||||
data-testid="em-president-back-to-schedule"
|
|
||||||
>
|
|
||||||
{isRtl ? (
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
<span className="hidden sm:inline">
|
|
||||||
{t("executiveMeetings.nav.schedule")}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1285,6 +1256,9 @@ function ExecutiveMeetingsPageInner() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
{/* #598: global app switcher. Hidden in schedule-fullscreen so
|
||||||
|
the calendar can fill the screen unobstructed. */}
|
||||||
|
{!isFullscreen && <AppDock currentSlug="executive-meetings" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ import {
|
|||||||
LabelMenu,
|
LabelMenu,
|
||||||
} from "@/components/notes/composer";
|
} from "@/components/notes/composer";
|
||||||
import { SendNoteDialog } from "@/components/notes/send-note-dialog";
|
import { SendNoteDialog } from "@/components/notes/send-note-dialog";
|
||||||
|
import { AppDock } from "@/components/app-dock";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
MouseSensor,
|
MouseSensor,
|
||||||
@@ -1497,6 +1498,7 @@ export default function NotesPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<AppDock currentSlug="notes" />
|
||||||
</div>
|
</div>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AppDock } from "@/components/app-dock";
|
||||||
|
|
||||||
export const ORDER_RECEIVER_ROLE = "order_receiver";
|
export const ORDER_RECEIVER_ROLE = "order_receiver";
|
||||||
|
|
||||||
@@ -731,6 +732,7 @@ export default function OrdersIncomingPage() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
<AppDock currentSlug="orders-incoming" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||||
import { OrderServiceModal } from "@/components/order-service-modal";
|
import { OrderServiceModal } from "@/components/order-service-modal";
|
||||||
|
import { AppDock } from "@/components/app-dock";
|
||||||
|
|
||||||
type CatalogService = {
|
type CatalogService = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -138,6 +139,7 @@ export default function ServicesPage() {
|
|||||||
service={orderTarget}
|
service={orderTarget}
|
||||||
resolveImage={resolveServiceImageUrl}
|
resolveImage={resolveServiceImageUrl}
|
||||||
/>
|
/>
|
||||||
|
<AppDock currentSlug="services" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user