diff --git a/artifacts/tx-os/src/components/settings-panel.tsx b/artifacts/tx-os/src/components/settings-panel.tsx
new file mode 100644
index 00000000..7b7586c7
--- /dev/null
+++ b/artifacts/tx-os/src/components/settings-panel.tsx
@@ -0,0 +1,540 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ useUpdateLanguage,
+ useUpdateClockStyle,
+ useUpdateClockHour12,
+ getGetMeQueryKey,
+ type AuthUser,
+ type ClockStyle as ClockStyleType,
+ type NotificationSoundId,
+} from "@workspace/api-client-react";
+import {
+ Settings as SettingsIcon,
+ BellOff,
+ Volume2,
+ VolumeX,
+ Check,
+ Eye,
+ EyeOff,
+ Play,
+} from "lucide-react";
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import {
+ Clock,
+ CLOCK_STYLES,
+ resolveClockStyle,
+ resolveClockHour12,
+ useNow,
+ useHomeClockVisibility,
+ useTopbarClockVisibility,
+} from "@/components/clock";
+import {
+ ALL_SOUND_IDS,
+ notificationPlayer,
+} from "@/lib/notification-sounds";
+import { useAuth } from "@/contexts/AuthContext";
+import { useUpdatePrefs } from "@/components/notification-settings";
+import i18n from "@/i18n";
+
+type Slot = "order" | "meeting" | "note";
+
+const SLOT_KEYS: Record<
+ Slot,
+ {
+ enabledKey: keyof Pick<
+ AuthUser,
+ "notifyOrdersEnabled" | "notifyMeetingsEnabled" | "notifyNotesEnabled"
+ >;
+ soundKey: keyof Pick<
+ AuthUser,
+ "notificationSoundOrder" | "notificationSoundMeeting" | "notificationSoundNote"
+ >;
+ vibrateKey: keyof Pick<
+ AuthUser,
+ "vibrationEnabledOrder" | "vibrationEnabledMeeting" | "vibrationEnabledNote"
+ >;
+ enabledLabel: string;
+ vibrateLabel: string;
+ }
+> = {
+ order: {
+ enabledKey: "notifyOrdersEnabled",
+ soundKey: "notificationSoundOrder",
+ vibrateKey: "vibrationEnabledOrder",
+ enabledLabel: "notifSettings.ordersEnabled",
+ vibrateLabel: "notifSettings.vibrationOrder",
+ },
+ meeting: {
+ enabledKey: "notifyMeetingsEnabled",
+ soundKey: "notificationSoundMeeting",
+ vibrateKey: "vibrationEnabledMeeting",
+ enabledLabel: "notifSettings.meetingsEnabled",
+ vibrateLabel: "notifSettings.vibrationMeeting",
+ },
+ note: {
+ enabledKey: "notifyNotesEnabled",
+ soundKey: "notificationSoundNote",
+ vibrateKey: "vibrationEnabledNote",
+ enabledLabel: "notifSettings.notesEnabled",
+ vibrateLabel: "notifSettings.vibrationNote",
+ },
+};
+
+function ToggleRow({
+ active,
+ onClick,
+ label,
+ leftIcon,
+}: {
+ active: boolean;
+ onClick: () => void;
+ label: string;
+ leftIcon?: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function SectionTitle({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function GroupCard({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function LanguageGroup() {
+ const { t, i18n: i18nInstance } = useTranslation();
+ const queryClient = useQueryClient();
+ const updateLanguage = useUpdateLanguage();
+ const current = i18nInstance.language === "ar" ? "ar" : "en";
+
+ const choose = (next: "ar" | "en") => {
+ if (next === current) return;
+ i18n.changeLanguage(next);
+ updateLanguage.mutate(
+ { data: { language: next } },
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
+ },
+ },
+ );
+ };
+
+ const options: Array<{ value: "ar" | "en"; label: string }> = [
+ { value: "ar", label: t("settingsPanel.lang.ar") },
+ { value: "en", label: t("settingsPanel.lang.en") },
+ ];
+
+ return (
+
+ {t("settingsPanel.section.language")}
+
+ {options.map((opt) => {
+ const isActive = opt.value === current;
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+function SoundList({
+ current,
+ onPick,
+}: {
+ current: NotificationSoundId;
+ onPick: (id: NotificationSoundId) => void;
+}) {
+ const { t } = useTranslation();
+ return (
+
+ {ALL_SOUND_IDS.map((id) => {
+ const isActive = id === current;
+ return (
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+function NotificationsGroup() {
+ const { t } = useTranslation();
+ const { user } = useAuth();
+ const apply = useUpdatePrefs();
+ const [slot, setSlot] = useState("order");
+ if (!user) return null;
+ const cfg = SLOT_KEYS[slot];
+ return (
+
+ {t("settingsPanel.section.notifications")}
+
+ {(["order", "meeting", "note"] as const).map((s) => {
+ const isActive = s === slot;
+ return (
+
+ );
+ })}
+
+
+ apply({ [cfg.enabledKey]: !user[cfg.enabledKey] } as Record)
+ }
+ label={t(cfg.enabledLabel)}
+ />
+
+ apply({ [cfg.vibrateKey]: !user[cfg.vibrateKey] } as Record)
+ }
+ label={t(cfg.vibrateLabel)}
+ />
+
+ apply({ [cfg.soundKey]: id } as Record)}
+ />
+
+ );
+}
+
+function SoundGroup() {
+ const { t } = useTranslation();
+ const { user } = useAuth();
+ const apply = useUpdatePrefs();
+ if (!user) return null;
+ const muted = user.notificationsMuted;
+ return (
+
+ {t("settingsPanel.section.sound")}
+ apply({ notificationsMuted: !muted })}
+ label={t("settingsPanel.sound.master")}
+ leftIcon={
+ muted ? (
+
+ ) : (
+
+ )
+ }
+ />
+
+ );
+}
+
+function ClockGroup() {
+ const { t, i18n: i18nInstance } = useTranslation();
+ const lang = i18nInstance.language;
+ const queryClient = useQueryClient();
+ const now = useNow();
+ const { user } = useAuth();
+ const updateClockStyle = useUpdateClockStyle();
+ const updateClockHour12 = useUpdateClockHour12();
+ const [homeClockVisible, setHomeClockVisible] = useHomeClockVisibility();
+ const [topbarClockVisible, setTopbarClockVisible] = useTopbarClockVisibility();
+
+ if (!user) return null;
+ const active = resolveClockStyle(user.clockStyle);
+ const activeHour12 = resolveClockHour12(user.clockHour12);
+
+ const chooseStyle = (style: ClockStyleType) => {
+ const previous = queryClient.getQueryData(getGetMeQueryKey());
+ if (previous) {
+ queryClient.setQueryData(getGetMeQueryKey(), { ...previous, clockStyle: style });
+ }
+ updateClockStyle.mutate(
+ { data: { clockStyle: style } },
+ {
+ onSuccess: (data) => queryClient.setQueryData(getGetMeQueryKey(), data),
+ onError: () => {
+ if (previous) queryClient.setQueryData(getGetMeQueryKey(), previous);
+ else queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
+ },
+ },
+ );
+ };
+
+ const chooseHour12 = (next: boolean) => {
+ if (next === activeHour12) return;
+ const previous = queryClient.getQueryData(getGetMeQueryKey());
+ if (previous) {
+ queryClient.setQueryData(getGetMeQueryKey(), { ...previous, clockHour12: next });
+ }
+ updateClockHour12.mutate(
+ { data: { clockHour12: next } },
+ {
+ onSuccess: (data) => queryClient.setQueryData(getGetMeQueryKey(), data),
+ onError: () => {
+ if (previous) queryClient.setQueryData(getGetMeQueryKey(), previous);
+ else queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
+ },
+ },
+ );
+ };
+
+ return (
+
+ {t("settingsPanel.section.clock")}
+ setHomeClockVisible(!homeClockVisible)}
+ label={t("home.clockStyle.showWidget")}
+ leftIcon={
+ homeClockVisible ? (
+
+ ) : (
+
+ )
+ }
+ />
+ setTopbarClockVisible(!topbarClockVisible)}
+ label={t("home.clockStyle.showTopbar")}
+ leftIcon={
+ topbarClockVisible ? (
+
+ ) : (
+
+ )
+ }
+ />
+
+
+ {t("home.clockStyle.hourFormat.label")}
+
+
+ {([false, true] as const).map((value) => {
+ const isActive = value === activeHour12;
+ const labelKey = value ? "h12" : "h24";
+ return (
+
+ );
+ })}
+
+
+
+ {t("home.clockStyle.label")}
+
+
+ {CLOCK_STYLES.map((style) => {
+ const isActive = style === active;
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+/**
+ * Unified per-user Settings panel (Task #527). Consolidates the four
+ * preferences that previously each had their own topbar button:
+ * - Language (was: globe button)
+ * - Notifications: per-category sound + vibration (was: bell #2 popover)
+ * - Sound: master mute toggle (was: volume button)
+ * - Clock: visibility, 12/24h, style (was: clock-style picker button)
+ *
+ * Renders as a right-side Sheet on every viewport — full-width on
+ * phones, capped width on desktop. Keeps the same persistence paths
+ * as the original controls (DB columns via the existing hooks +
+ * localStorage for clock visibility), so no migration is required.
+ */
+export function SettingsPanel() {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const { user } = useAuth();
+ if (!user) return null;
+ const muted = user.notificationsMuted;
+ return (
+
+
+
+
+
+
+
+
+ {t("settingsPanel.title")}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json
index 24fb93a0..7dfa508e 100644
--- a/artifacts/tx-os/src/locales/ar.json
+++ b/artifacts/tx-os/src/locales/ar.json
@@ -204,6 +204,22 @@
"deleteFailed": "تعذّر حذف الطلبات",
"deletePartial": "تم حذف {{deleted}} من {{total}}، تعذّر حذف الباقي"
},
+ "settingsPanel": {
+ "title": "الإعدادات",
+ "section": {
+ "language": "اللغة",
+ "notifications": "الإشعارات",
+ "sound": "الصوت",
+ "clock": "الساعة"
+ },
+ "lang": {
+ "ar": "العربية",
+ "en": "English"
+ },
+ "sound": {
+ "master": "تشغيل أصوات الإشعارات"
+ }
+ },
"notifSettings": {
"title": "أصوات الإشعارات",
"mute": "كتم الإشعارات",
diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json
index 4e6ed271..339d4465 100644
--- a/artifacts/tx-os/src/locales/en.json
+++ b/artifacts/tx-os/src/locales/en.json
@@ -210,6 +210,22 @@
"noNotifications": "No new notifications",
"unread": "Unread"
},
+ "settingsPanel": {
+ "title": "Settings",
+ "section": {
+ "language": "Language",
+ "notifications": "Notifications",
+ "sound": "Sound",
+ "clock": "Clock"
+ },
+ "lang": {
+ "ar": "العربية",
+ "en": "English"
+ },
+ "sound": {
+ "master": "Play notification sounds"
+ }
+ },
"notifSettings": {
"title": "Notification sounds",
"mute": "Mute notifications",
diff --git a/artifacts/tx-os/src/pages/home.tsx b/artifacts/tx-os/src/pages/home.tsx
index 0ab28b3e..771664b0 100644
--- a/artifacts/tx-os/src/pages/home.tsx
+++ b/artifacts/tx-os/src/pages/home.tsx
@@ -11,7 +11,6 @@ import {
useListIncomingServiceOrders,
getListIncomingServiceOrdersQueryKey,
useLogout,
- useUpdateLanguage,
useUpdateMyAppOrder,
logAppOpen,
getGetMeQueryKey,
@@ -25,7 +24,6 @@ import {
Bell,
Inbox,
LogOut,
- Globe,
Grid2X2,
Coffee,
BellRing,
@@ -33,7 +31,6 @@ import {
} from "lucide-react";
import * as LucideIcons from "lucide-react";
import type { LucideIcon } from "lucide-react";
-import i18n from "@/i18n";
import {
DndContext,
PointerSensor,
@@ -66,11 +63,7 @@ import {
useHomeClockPosition,
type HomeClockSize,
} from "@/components/clock";
-import { ClockStylePicker } from "@/components/clock-style-picker";
-import {
- NotificationSettingsPicker,
- QuickMuteButton,
-} from "@/components/notification-settings";
+import { SettingsPanel } from "@/components/settings-panel";
type IconName = keyof typeof LucideIcons;
function isIconName(x: string): x is IconName {
@@ -479,7 +472,6 @@ export default function HomePage() {
const notesPulse = notePopupQueueLength > 0;
const logout = useLogout();
- const updateLanguage = useUpdateLanguage();
const openApp = (app: App) => {
// Fire-and-forget: keepalive ensures the request survives any
@@ -519,15 +511,6 @@ export default function HomePage() {
});
};
- const toggleLanguage = () => {
- const newLang = lang === "ar" ? "en" : "ar";
- i18n.changeLanguage(newLang);
- updateLanguage.mutate(
- { data: { language: newLang } },
- { onSuccess: () => queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }) },
- );
- };
-
const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "notifications", "admin"].includes(a.slug)) ?? [];
const initials = (displayName || "?").trim().slice(0, 1).toUpperCase();
@@ -582,20 +565,17 @@ export default function HomePage() {
numeric `size` prop and use Tailwind classes so the icon
itself can scale at the `md:` breakpoint.
*/}
+ {/*
+ Task #527: the topbar's right-side cluster used to host five
+ icon buttons (clock-style, quick-mute, notification-settings,
+ language toggle, plus the inbox/notifications/logout below).
+ Per-user UI preferences (language, notification sound +
+ vibration, master sound, clock style/visibility/12-24h) now
+ live behind a single Settings (gear) entry that opens the
+ unified panel. The notification-center bell stays as a
+ navigation link — it's the only bell on the page now.
+ */}
-
-
-
-
{canReceiveOrders && (
+