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 (
<>
);
}
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.
*/}