import { useState, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "wouter"; import { useListApps, getListAppsQueryKey, useGetHomeStats, getGetHomeStatsQueryKey, useListNotifications, getListNotificationsQueryKey, useListIncomingServiceOrders, getListIncomingServiceOrdersQueryKey, useLogout, useUpdateMyAppOrder, logAppOpen, getGetMeQueryKey, type App, } from "@workspace/api-client-react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/contexts/AuthContext"; import { usePushSubscription } from "@/hooks/use-push-subscription"; import { useReceivedNotes } from "@/lib/notes-api"; import { useIncomingNotePopup } from "@/contexts/IncomingNotePopupContext"; import { Bell, Inbox, LogOut, Grid2X2, Coffee, BellRing, Sparkles, } from "lucide-react"; import * as LucideIcons from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { DndContext, PointerSensor, TouchSensor, useSensor, useSensors, closestCenter, type DragEndEvent, } from "@dnd-kit/core"; import { SortableContext, arrayMove, rectSortingStrategy, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { formatDate, formatTime, formatNumber, } from "@/lib/i18n-format"; import { resolveServiceImageUrl } from "@/lib/image-url"; import { Clock, useNow, AnalogClockWidget, useHomeClockVisibility, useTopbarClockVisibility, useHomeClockSize, useHomeClockPosition, type HomeClockSize, } from "@/components/clock"; import { SettingsPanel } from "@/components/settings-panel"; 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; } function gradientForColor(color: string): string { const c = (color ?? "").toLowerCase(); // #603: each built-in app now ships with a distinct hue so the four // default tiles (Notes/Services/Meetings/Admin) read as four different // colours instead of three blues + one orange. New patterns added for // yellow (#eab308 / facc15) and red (#ef4444 / dc2626), and the green // branch already accepts the new #22c55e Meetings colour. The purple // branch stays for any admin who manually picks a purple via the // Apps editor, but no seeded row uses it any more. if (c.includes("ef4444") || c.includes("dc2626") || c.includes("red")) return "icon-tile-red"; if (c.includes("eab308") || c.includes("facc15") || c.includes("yellow")) return "icon-tile-yellow"; if (c.includes("a855f7") || c.includes("8b5cf6") || c.includes("purple") || c.includes("violet")) return "icon-tile-purple"; if (c.includes("10b981") || c.includes("22c55e") || c.includes("green") || c.includes("emerald")) return "icon-tile-green"; if (c.includes("f59e0b") || c.includes("f97316") || c.includes("orange") || c.includes("amber")) return "icon-tile-orange"; if (c.includes("ec4899") || c.includes("pink") || c.includes("rose")) return "icon-tile-pink"; if (c.includes("14b8a6") || c.includes("06b6d4") || c.includes("teal") || c.includes("cyan")) return "icon-tile-teal"; return "icon-tile-blue"; } function AppIconContent({ app, badgeCount = 0, pulse = false, }: { app: { nameAr: string; nameEn: string; iconName: string; color: string; imageUrl?: string | null }; badgeCount?: number; pulse?: boolean; }) { const { i18n: i18nInstance } = useTranslation(); const lang = i18nInstance.language; const name = lang === "ar" ? app.nameAr : app.nameEn; const Icon = resolveIcon(app.iconName); // when an admin uploads a custom image, render it in place // of the Lucide icon. The image fills the tile (object-cover) so the // gradient background still shows through transparent PNGs. const resolvedImage = resolveServiceImageUrl(app.imageUrl ?? null); return ( <>
{pulse && (
{name} ); } function SortableAppIcon({ app, onClick, badgeCount = 0, pulse = false, }: { app: App; onClick: () => void; badgeCount?: number; pulse?: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `app-${app.id}` }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1, zIndex: isDragging ? 20 : "auto", touchAction: "none", }; return ( ); } const CLOCK_TILE_ID = "__clock"; function useGridCols(): number { const compute = () => { if (typeof window === "undefined") return 8; const w = window.innerWidth; if (w >= 1024) return 8; if (w >= 768) return 7; if (w >= 640) return 6; return 4; }; const [cols, setCols] = useState(compute); useEffect(() => { const onResize = () => setCols(compute()); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); return cols; } function SortableClockTile({ time, lang, hour12, size, onToggleSize, pinColumn, }: { time: Date; lang: string; hour12: boolean | null | undefined; size: HomeClockSize; onToggleSize: () => void; pinColumn?: number; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: CLOCK_TILE_ID, }); const isLarge = size === "large"; const px = isLarge ? 168 : 78; const effectivePinColumn = pinColumn && isLarge ? Math.max(pinColumn - 1, 1) : pinColumn; const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.6 : 1, zIndex: isDragging ? 20 : "auto", touchAction: "none", ...(effectivePinColumn && !isDragging ? { gridColumnStart: effectivePinColumn, gridRowStart: 1 } : {}), }; const longPressTimer = useRef(null); const startPos = useRef<{ x: number; y: number } | null>(null); const clearTimer = () => { if (longPressTimer.current !== null) { window.clearTimeout(longPressTimer.current); longPressTimer.current = null; } }; useEffect(() => { if (isDragging) clearTimer(); }, [isDragging]); const dndPointerDown = listeners?.onPointerDown as | ((e: React.PointerEvent) => void) | undefined; const onPointerDown = (e: React.PointerEvent) => { startPos.current = { x: e.clientX, y: e.clientY }; clearTimer(); longPressTimer.current = window.setTimeout(() => { onToggleSize(); }, 500); dndPointerDown?.(e); }; const onPointerMove = (e: React.PointerEvent) => { if (!startPos.current) return; const dx = e.clientX - startPos.current.x; const dy = e.clientY - startPos.current.y; if (dx * dx + dy * dy > 64) clearTimer(); }; const onPointerUp = () => { clearTimer(); startPos.current = null; }; const { onPointerDown: _ignored, ...restListeners } = listeners ?? {}; return (
{formatTime(time, lang, { hour: "2-digit", minute: "2-digit", hour12: resolveClockHour12Bool(hour12), })}
); } function resolveClockHour12Bool(v: boolean | null | undefined): boolean { return v ?? false; } type StatCardProps = { label: string; value: string; Icon: typeof Grid2X2; iconClass: string; }; function StatCard({ label, value, Icon, iconClass }: StatCardProps) { return (
{value}
{label}
); } export default function HomePage() { const { t, i18n: i18nInstance } = useTranslation(); const [, setLocation] = useLocation(); const { user } = useAuth(); const queryClient = useQueryClient(); const time = useNow(); const lang = i18nInstance.language; const { data: apps } = useListApps({ query: { queryKey: getListAppsQueryKey() } }); const updateOrder = useUpdateMyAppOrder(); const [orderedApps, setOrderedApps] = useState(null); useEffect(() => { if (!apps) return; const activeApps = apps.filter((a) => a.isActive); setOrderedApps((prev) => { if (!prev) return activeApps; const byId = new Map(activeApps.map((a) => [a.id, a])); const kept = prev .map((a) => byId.get(a.id)) .filter((a): a is App => a !== undefined); const keptIds = new Set(kept.map((a) => a.id)); const added = activeApps.filter((a) => !keptIds.has(a.id)); return [...kept, ...added]; }); }, [apps]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }), ); const gridCols = useGridCols(); const [homeClockVisible, setHomeClockVisible] = useHomeClockVisibility(); const [topbarClockVisible] = useTopbarClockVisibility(); const [clockSize, setClockSize] = useHomeClockSize(user?.id); const [clockPos, setClockPos] = useHomeClockPosition(user?.id); const toggleClockSize = () => setClockSize(clockSize === "large" ? "compact" : "large"); const appCount = (orderedApps ?? []).length; const isClockUnset = clockPos < 0; // Only meaningful in RTL: when the user drags the clock past the last // app, treat it as "pin to the absolute far-left cell" so they can // reach that cell even when there are fewer apps than columns. // In LTR the far-right is already reachable naturally so we don't // hijack the saved index there. const isRtlClockAtEnd = lang === "ar" && clockPos >= appCount; const shouldPinFarLeft = (isClockUnset || isRtlClockAtEnd) && lang === "ar" && homeClockVisible; const combinedIds = useMemo(() => { const appIds = (orderedApps ?? []).map((a) => `app-${a.id}`); if (!homeClockVisible) return appIds; if (isClockUnset) { // Default placement: LTR -> front of array (visually leftmost); // RTL -> appended to end and CSS-pinned to the last column of row 1. return lang === "ar" ? [...appIds, CLOCK_TILE_ID] : [CLOCK_TILE_ID, ...appIds]; } if (isRtlClockAtEnd) { // RTL only: user dragged clock to / past the end -> pin far-left. return [...appIds, CLOCK_TILE_ID]; } const safePos = Math.min(Math.max(clockPos, 0), appIds.length); const out: string[] = [...appIds]; out.splice(safePos, 0, CLOCK_TILE_ID); return out; }, [orderedApps, clockPos, homeClockVisible, lang, isClockUnset, isRtlClockAtEnd]); const clockPinColumn = shouldPinFarLeft ? Math.max(gridCols, 1) : undefined; const sortableIds = combinedIds; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id || !orderedApps) return; const oldIndex = combinedIds.findIndex((id) => id === active.id); const newIndex = combinedIds.findIndex((id) => id === over.id); if (oldIndex < 0 || newIndex < 0) return; const nextCombined = arrayMove(combinedIds, oldIndex, newIndex); const newClockPos = nextCombined.findIndex((id) => id === CLOCK_TILE_ID); if (newClockPos >= 0 && newClockPos !== clockPos) setClockPos(newClockPos); const newAppOrder = nextCombined .filter((id) => id !== CLOCK_TILE_ID) .map((id) => Number(String(id).slice(4))) .map((id) => orderedApps.find((a) => a.id === id)) .filter((a): a is App => a !== undefined); const sameAppOrder = newAppOrder.length === orderedApps.length && newAppOrder.every((a, i) => a.id === orderedApps[i].id); if (sameAppOrder) return; const previous = orderedApps; setOrderedApps(newAppOrder); updateOrder.mutate( { data: { order: newAppOrder.map((a) => a.id) } }, { onSuccess: (data) => { setOrderedApps(data); queryClient.setQueryData(getListAppsQueryKey(), data); }, onError: () => { setOrderedApps(previous); queryClient.invalidateQueries({ queryKey: getListAppsQueryKey() }); }, }, ); }; const { data: stats } = useGetHomeStats({ query: { queryKey: getGetHomeStatsQueryKey() } }); const { data: notifications } = useListNotifications({ query: { queryKey: getListNotificationsQueryKey() } }); const isAdmin = user?.roles?.includes("admin") ?? false; const canReceiveOrders = isAdmin || (user?.roles?.includes("order_receiver") ?? false); const { data: incomingOrders } = useListIncomingServiceOrders({ query: { queryKey: getListIncomingServiceOrdersQueryKey(), enabled: canReceiveOrders, }, }); const pendingOrdersCount = incomingOrders?.filter((o) => o.status === "pending" && !o.assignedTo).length ?? 0; // Unread Notes badge — drives the count shown on the Notes app tile (and // dock entry, if present). We reuse the existing /notes/received hook so // socket invalidations on note_received automatically refresh this. const { data: receivedNotes } = useReceivedNotes(false); const unreadNotesCount = receivedNotes?.filter((n) => n.status === "unread").length ?? 0; // Persistent visual cue while an incoming-note popup is queued: the Notes // tile pulses + shows the queue size as its badge so the user knows there's // an attention-grabbing note waiting even after they dismiss the modal or // navigate elsewhere on the home screen. const { queueLength: notePopupQueueLength } = useIncomingNotePopup(); const notesBadgeCount = Math.max(unreadNotesCount, notePopupQueueLength); const notesPulse = notePopupQueueLength > 0; const logout = useLogout(); const pushSub = usePushSubscription(); const openApp = (app: App) => { // Fire-and-forget: keepalive ensures the request survives any // imminent navigation triggered by setLocation below. logAppOpen(app.id, { keepalive: true }).catch(() => { // Best-effort tracking; ignore failures so navigation isn't blocked. }); // branch on the admin-configured open mode. // external_tab — open the externalUrl in a new tab // external_iframe — navigate to /embedded/:id which renders externalUrl // inside an iframe shell with a back button // internal (default) — navigate to the SPA route 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); }; const unreadCount = notifications?.filter((n) => !n.isRead).length ?? stats?.unreadNotifications ?? 0; const displayName = lang === "ar" ? (user?.displayNameAr ?? user?.username ?? "") : (user?.displayNameEn ?? user?.username ?? ""); const handleLogout = async () => { // #626: tear down this browser's Web Push subscription BEFORE // hitting /auth/logout, so the local pushManager endpoint and the // server row both go away. The server-side logout also deletes // every push_subscriptions row for this user as a safety net, but // doing the browser unsubscribe first means the OS-level // subscription is gone too — otherwise the SW would keep an // orphan endpoint that the next signed-in user could inherit on // resubscribe. // Race the browser unsubscribe against a 1.5s timeout. `disable()` // awaits `navigator.serviceWorker.ready`, which can hang forever // if the SW never activates (rare but observed on first iOS PWA // launch). A stalled unsubscribe must not block the user from // signing out — the server-side delete in /auth/logout is the // authoritative cleanup, so a missed browser unsubscribe just // leaves an orphan pushManager endpoint that the OS will GC on // its own schedule. try { await Promise.race([ pushSub.disable(), new Promise((resolve) => setTimeout(resolve, 1500)), ]); } catch { // Best-effort: a failed unsubscribe must not block sign-out. } logout.mutate(undefined, { onSuccess: () => { queryClient.clear(); setLocation("/login"); }, }); }; // #571: drop the Notifications tile from the bottom dock — the bell // icon in the top bar (with the same unread badge) is the canonical // entry point, so a second affordance for the same destination on // the home screen was redundant. The bell still routes to // `/notifications` (see the top-bar button below). const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "admin"].includes(a.slug)) ?? []; const initials = (displayName || "?").trim().slice(0, 1).toUpperCase(); return ( // removed `overflow-hidden` and switched to `min-h-[100dvh]` // so iOS Safari can collapse its URL bar on scroll, matching the // executive-meetings page behavior. `100dvh` (dynamic viewport // height) sizes against the visible area, so the layout reflows // cleanly when the URL bar shows/hides.
{/* Status Bar -------------------------------------------------------------- Sizing scales by device class so the bar feels natural at each size — phones stay compact (the original design), iPad gets a noticeably taller bar with bigger tap targets so it doesn't look lost on a larger screen, and desktop matches iPad. We switch at the `md:` breakpoint (≥768px), which is the standard iPad-portrait threshold; mobile (<768px) renders identically to before this change. */}
{/* Identity */}
{initials}
{displayName}
{isAdmin && (
Admin
)}
{/* Clock + dates */} {topbarClockVisible && (
)} {/* Controls — buttons grow on iPad+ via padding + icon size. Lucide icons size via `width`/`height` SVG attrs; we drop the numeric `size` prop and use Tailwind classes so the icon itself can scale at the `md:` breakpoint. */} {/* : per-user UI preferences (language, notification sound + vibration, master sound, clock style/visibility/12-24h) live behind a single Settings (gear) entry that opens the unified panel. Logout stays on the topbar as a single-tap action ( reverted the brief move into the panel). The cluster now shows exactly Bell -> Inbox -> Settings -> Logout (Inbox is conditional on the user being able to receive incoming orders). The notification-center bell is the only bell on the page. */}
{canReceiveOrders && ( )}
{/* Main Content */}
{/* Stats Row — admin only */} {isAdmin && stats && (
)} {/* Apps Grid */}
{orderedApps && orderedApps.length === 0 ? (
{t("home.noApps")}
{t("home.noAppsHint")}
) : (
{combinedIds.map((id) => { if (id === CLOCK_TILE_ID) { if (!homeClockVisible) return null; return ( ); } const appId = Number(String(id).slice(4)); const app = orderedApps?.find((a) => a.id === appId); if (!app) return null; return ( openApp(app)} badgeCount={app.slug === "notes" ? notesBadgeCount : 0} pulse={app.slug === "notes" && notesPulse} /> ); })}
)}
{/* Bottom Dock */}
{dockApps.slice(0, 4).map((app) => { const Icon = resolveIcon(app.iconName); const showBadge = app.slug === "notifications" && unreadCount > 0; return ( ); })}
); }