@open-mercato/ui 0.6.6-develop.6314.1.c7b8291aa2 → 0.6.6-develop.6330.1.a261878aa8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/AppShell.js +2 -1
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +2 -1
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/OrganizationScopeBoundary.js +33 -0
- package/dist/backend/OrganizationScopeBoundary.js.map +7 -0
- package/dist/backend/notifications/NotificationPanel.js +14 -28
- package/dist/backend/notifications/NotificationPanel.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +4 -1
- package/src/backend/CrudForm.tsx +4 -1
- package/src/backend/OrganizationScopeBoundary.tsx +54 -0
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +59 -0
- package/src/backend/__tests__/OrganizationScopeBoundary.test.tsx +74 -0
- package/src/backend/notifications/NotificationPanel.tsx +35 -38
- package/src/backend/notifications/__tests__/NotificationPanel.test.tsx +124 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
subscribeOrganizationScopeChanged
|
|
6
|
+
} from "@open-mercato/shared/lib/frontend/organizationEvents";
|
|
7
|
+
function OrganizationScopeBoundary({ active, children }) {
|
|
8
|
+
const [scopeKey, setScopeKey] = React.useState(0);
|
|
9
|
+
const lastScopeRef = React.useRef(null);
|
|
10
|
+
const hasInitializedScopeRef = React.useRef(false);
|
|
11
|
+
React.useEffect(() => {
|
|
12
|
+
return subscribeOrganizationScopeChanged((detail) => {
|
|
13
|
+
const prev = lastScopeRef.current;
|
|
14
|
+
lastScopeRef.current = detail;
|
|
15
|
+
if (!hasInitializedScopeRef.current) {
|
|
16
|
+
hasInitializedScopeRef.current = true;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (prev && prev.organizationId === detail.organizationId && prev.tenantId === detail.tenantId) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
setScopeKey((current) => current + 1);
|
|
23
|
+
});
|
|
24
|
+
}, []);
|
|
25
|
+
if (!active) {
|
|
26
|
+
return /* @__PURE__ */ jsx(Fragment, { children });
|
|
27
|
+
}
|
|
28
|
+
return /* @__PURE__ */ jsx(React.Fragment, { children }, scopeKey);
|
|
29
|
+
}
|
|
30
|
+
export {
|
|
31
|
+
OrganizationScopeBoundary
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=OrganizationScopeBoundary.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/backend/OrganizationScopeBoundary.tsx"],
|
|
4
|
+
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport {\n subscribeOrganizationScopeChanged,\n type OrganizationScopeChangedDetail,\n} from '@open-mercato/shared/lib/frontend/organizationEvents'\n\nexport type OrganizationScopeBoundaryProps = {\n active: boolean\n children: React.ReactNode\n}\n\n/**\n * Remounts its children whenever the active organization/tenant scope changes.\n *\n * The org/tenant switcher already calls `router.refresh()` on a scope change,\n * which re-runs server components but does NOT remount client components \u2014 so\n * settings pages that fetch their data once in a mount `useEffect` keep showing\n * the previous scope's values until a manual reload. Wrapping those pages here\n * forces a fresh mount (and therefore a refetch) on a real scope change.\n *\n * Gated by `active` so only the settings surface remounts; list/CRUD pages keep\n * relying on the smoother `router.refresh()` path that preserves table state.\n */\nexport function OrganizationScopeBoundary({ active, children }: OrganizationScopeBoundaryProps) {\n const [scopeKey, setScopeKey] = React.useState(0)\n const lastScopeRef = React.useRef<OrganizationScopeChangedDetail | null>(null)\n const hasInitializedScopeRef = React.useRef(false)\n\n React.useEffect(() => {\n return subscribeOrganizationScopeChanged((detail) => {\n const prev = lastScopeRef.current\n lastScopeRef.current = detail\n if (!hasInitializedScopeRef.current) {\n hasInitializedScopeRef.current = true\n return\n }\n if (\n prev &&\n prev.organizationId === detail.organizationId &&\n prev.tenantId === detail.tenantId\n ) {\n return\n }\n setScopeKey((current) => current + 1)\n })\n }, [])\n\n if (!active) {\n return <>{children}</>\n }\n\n return <React.Fragment key={scopeKey}>{children}</React.Fragment>\n}\n"],
|
|
5
|
+
"mappings": ";AAiDW;AAhDX,YAAY,WAAW;AACvB;AAAA,EACE;AAAA,OAEK;AAmBA,SAAS,0BAA0B,EAAE,QAAQ,SAAS,GAAmC;AAC9F,QAAM,CAAC,UAAU,WAAW,IAAI,MAAM,SAAS,CAAC;AAChD,QAAM,eAAe,MAAM,OAA8C,IAAI;AAC7E,QAAM,yBAAyB,MAAM,OAAO,KAAK;AAEjD,QAAM,UAAU,MAAM;AACpB,WAAO,kCAAkC,CAAC,WAAW;AACnD,YAAM,OAAO,aAAa;AAC1B,mBAAa,UAAU;AACvB,UAAI,CAAC,uBAAuB,SAAS;AACnC,+BAAuB,UAAU;AACjC;AAAA,MACF;AACA,UACE,QACA,KAAK,mBAAmB,OAAO,kBAC/B,KAAK,aAAa,OAAO,UACzB;AACA;AAAA,MACF;AACA,kBAAY,CAAC,YAAY,UAAU,CAAC;AAAA,IACtC,CAAC;AAAA,EACH,GAAG,CAAC,CAAC;AAEL,MAAI,CAAC,QAAQ;AACX,WAAO,gCAAG,UAAS;AAAA,EACrB;AAEA,SAAO,oBAAC,MAAM,UAAN,EAA+B,YAAX,QAAoB;AAClD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -6,6 +6,7 @@ import { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from "luci
|
|
|
6
6
|
import { Button } from "../../primitives/button.js";
|
|
7
7
|
import { IconButton } from "../../primitives/icon-button.js";
|
|
8
8
|
import { Sheet, SheetContent, SheetTitle } from "../../primitives/sheet.js";
|
|
9
|
+
import { Tabs, TabsList, TabsTrigger } from "../../primitives/tabs.js";
|
|
9
10
|
import { NotificationItem } from "./NotificationItem.js";
|
|
10
11
|
function NotificationPanel({
|
|
11
12
|
open,
|
|
@@ -43,6 +44,11 @@ function NotificationPanel({
|
|
|
43
44
|
setMarkingAllRead(false);
|
|
44
45
|
}
|
|
45
46
|
};
|
|
47
|
+
const handleFilterChange = React.useCallback((next) => {
|
|
48
|
+
if (next === "all" || next === "unread" || next === "action") {
|
|
49
|
+
setFilter(next);
|
|
50
|
+
}
|
|
51
|
+
}, []);
|
|
46
52
|
React.useEffect(() => {
|
|
47
53
|
if (!open || typeof document === "undefined") return;
|
|
48
54
|
const previous = document.body.style.overflow;
|
|
@@ -63,12 +69,14 @@ function NotificationPanel({
|
|
|
63
69
|
/* @__PURE__ */ jsx(SheetTitle, { className: "text-base font-medium leading-6 tracking-tight text-foreground", children: t("notifications.title", "Notifications") }),
|
|
64
70
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
|
|
65
71
|
unreadCount > 0 ? /* @__PURE__ */ jsxs(
|
|
66
|
-
|
|
72
|
+
Button,
|
|
67
73
|
{
|
|
68
74
|
type: "button",
|
|
75
|
+
variant: "link",
|
|
76
|
+
size: "sm",
|
|
69
77
|
onClick: handleMarkAllRead,
|
|
70
78
|
disabled: markingAllRead,
|
|
71
|
-
className: "
|
|
79
|
+
className: "gap-1 px-1 text-accent-indigo hover:text-accent-indigo/80 hover:no-underline",
|
|
72
80
|
children: [
|
|
73
81
|
markingAllRead ? /* @__PURE__ */ jsx(Loader2, { className: "size-3.5 animate-spin" }) : null,
|
|
74
82
|
t("notifications.markAllRead", "Mark all read")
|
|
@@ -88,33 +96,11 @@ function NotificationPanel({
|
|
|
88
96
|
)
|
|
89
97
|
] })
|
|
90
98
|
] }),
|
|
91
|
-
/* @__PURE__ */ jsx(
|
|
92
|
-
const isActive = filter === value;
|
|
99
|
+
/* @__PURE__ */ jsx(Tabs, { value: filter, onValueChange: handleFilterChange, variant: "underline", children: /* @__PURE__ */ jsx(TabsList, { className: "flex w-full px-5", children: ["all", "unread", "action"].map((value) => {
|
|
93
100
|
const label = value === "all" ? t("notifications.filters.all", "All") : value === "unread" ? t("notifications.filters.unread", "Unread") : t("notifications.filters.actionRequired", "Action Required");
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
type: "button",
|
|
98
|
-
role: "tab",
|
|
99
|
-
"aria-selected": isActive,
|
|
100
|
-
onClick: () => setFilter(value),
|
|
101
|
-
className: "relative inline-flex items-center gap-1.5 pb-2 text-sm font-medium leading-5 tracking-tight transition-colors focus:outline-none focus-visible:text-foreground data-[active=true]:text-foreground data-[active=false]:text-muted-foreground hover:text-foreground",
|
|
102
|
-
"data-active": isActive,
|
|
103
|
-
children: [
|
|
104
|
-
/* @__PURE__ */ jsx("span", { children: label }),
|
|
105
|
-
value === "unread" && unreadCount > 0 ? /* @__PURE__ */ jsx("span", { className: "inline-flex min-w-4 items-center justify-center rounded-full bg-accent-indigo px-1 text-overline font-medium text-accent-indigo-foreground", children: unreadCount > 99 ? "99+" : unreadCount }) : null,
|
|
106
|
-
isActive ? /* @__PURE__ */ jsx(
|
|
107
|
-
"span",
|
|
108
|
-
{
|
|
109
|
-
className: "absolute bottom-[-14px] left-0 right-0 h-0.5 bg-foreground",
|
|
110
|
-
"aria-hidden": "true"
|
|
111
|
-
}
|
|
112
|
-
) : null
|
|
113
|
-
]
|
|
114
|
-
},
|
|
115
|
-
value
|
|
116
|
-
);
|
|
117
|
-
}) }),
|
|
101
|
+
const count = value === "unread" && unreadCount > 0 ? unreadCount > 99 ? "99+" : unreadCount : void 0;
|
|
102
|
+
return /* @__PURE__ */ jsx(TabsTrigger, { value, count, children: label }, value);
|
|
103
|
+
}) }) }),
|
|
118
104
|
dismissUndo && onUndoDismiss && /* @__PURE__ */ jsx("div", { className: "border-b bg-muted/50 px-4 py-2 text-sm", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3", children: [
|
|
119
105
|
/* @__PURE__ */ jsx("span", { children: t("notifications.toast.dismissed", "Notification dismissed") }),
|
|
120
106
|
/* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", onClick: () => onUndoDismiss(), children: [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/notifications/NotificationPanel.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport { Sheet, SheetContent, SheetTitle } from '../../primitives/sheet'\nimport { NotificationItem } from './NotificationItem'\nimport type { NotificationDto, NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport type { ComponentType } from 'react'\n\n/**\n * Map of notification type to custom renderer component.\n * Used to provide custom rendering for specific notification types.\n *\n * @example\n * ```tsx\n * const customRenderers = {\n * 'sales.order.created': SalesOrderCreatedRenderer,\n * 'sales.quote.created': SalesQuoteCreatedRenderer,\n * }\n * ```\n */\nexport type NotificationRenderers = Record<string, ComponentType<NotificationRendererProps>>\n\nexport type NotificationPanelProps = {\n open: boolean\n onOpenChange: (open: boolean) => void\n notifications: NotificationDto[]\n unreadCount: number\n onMarkAsRead: (id: string) => Promise<void>\n onExecuteAction: (id: string, actionId: string) => Promise<{ href?: string }>\n onDismiss: (id: string) => Promise<void>\n dismissUndo?: { notification: NotificationDto; previousStatus: 'read' | 'unread' } | null\n onUndoDismiss?: () => Promise<void>\n onMarkAllRead: () => Promise<void>\n t: TranslateFn\n /**\n * Optional map of notification type to custom renderer component.\n * When a notification's type matches a key in this map, the corresponding\n * renderer will be used instead of the default NotificationItem rendering.\n *\n * @example\n * ```tsx\n * import { salesNotificationTypes } from '@open-mercato/core/modules/sales/notifications.client'\n *\n * // Build renderers map from notification types\n * const renderers = Object.fromEntries(\n * salesNotificationTypes\n * .filter(t => t.Renderer)\n * .map(t => [t.type, t.Renderer!])\n * )\n *\n * <NotificationPanel customRenderers={renderers} ... />\n * ```\n */\n customRenderers?: NotificationRenderers\n}\n\nexport function NotificationPanel({\n open,\n onOpenChange,\n notifications,\n unreadCount,\n onMarkAsRead,\n onExecuteAction,\n onDismiss,\n dismissUndo,\n onUndoDismiss,\n onMarkAllRead,\n t,\n customRenderers,\n}: NotificationPanelProps) {\n const [filter, setFilter] = React.useState<'all' | 'unread' | 'action'>('all')\n const [markingAllRead, setMarkingAllRead] = React.useState(false)\n\n const filteredNotifications = React.useMemo(() => {\n switch (filter) {\n case 'unread':\n return notifications.filter((n) => n.status === 'unread')\n case 'action':\n return notifications.filter(\n (n) => n.actions && n.actions.length > 0 && n.status !== 'actioned'\n )\n default:\n return notifications\n }\n }, [notifications, filter])\n\n const handleMarkAllRead = async () => {\n setMarkingAllRead(true)\n try {\n await onMarkAllRead()\n } finally {\n setMarkingAllRead(false)\n }\n }\n\n // Preserve the body scroll lock contract that consumers (and integration\n // tests) rely on. Radix Dialog locks scroll via react-remove-scroll which\n // does not set `document.body.style.overflow` \u2014 we set it ourselves so the\n // contract is observable.\n React.useEffect(() => {\n if (!open || typeof document === 'undefined') return\n const previous = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n return () => {\n document.body.style.overflow = previous\n }\n }, [open])\n\n return (\n <Sheet open={open} onOpenChange={onOpenChange}>\n <SheetContent\n side=\"right\"\n className=\"gap-0 p-0\"\n hideClose\n aria-label={t('notifications.title', 'Notifications')}\n >\n <div className=\"flex items-center justify-between gap-3 border-b px-5 py-4\">\n <SheetTitle className=\"text-base font-medium leading-6 tracking-tight text-foreground\">\n {t('notifications.title', 'Notifications')}\n </SheetTitle>\n <div className=\"flex items-center gap-1\">\n {unreadCount > 0 ? (\n <button\n type=\"button\"\n onClick={handleMarkAllRead}\n disabled={markingAllRead}\n className=\"inline-flex items-center gap-1 rounded-md px-1 text-sm font-medium leading-5 text-accent-indigo transition-colors hover:text-accent-indigo/80 disabled:opacity-50\"\n >\n {markingAllRead ? <Loader2 className=\"size-3.5 animate-spin\" /> : null}\n {t('notifications.markAllRead', 'Mark all read')}\n </button>\n ) : null}\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => onOpenChange(false)}\n aria-label={t('notifications.close', 'Close notifications')}\n >\n <X className=\"size-4\" />\n </IconButton>\n </div>\n </div>\n\n <div role=\"tablist\" className=\"flex items-center gap-5 border-b px-5 py-3.5\">\n {(['all', 'unread', 'action'] as const).map((value) => {\n const isActive = filter === value\n const label =\n value === 'all'\n ? t('notifications.filters.all', 'All')\n : value === 'unread'\n ? t('notifications.filters.unread', 'Unread')\n : t('notifications.filters.actionRequired', 'Action Required')\n return (\n <button\n key={value}\n type=\"button\"\n role=\"tab\"\n aria-selected={isActive}\n onClick={() => setFilter(value)}\n className=\"relative inline-flex items-center gap-1.5 pb-2 text-sm font-medium leading-5 tracking-tight transition-colors focus:outline-none focus-visible:text-foreground data-[active=true]:text-foreground data-[active=false]:text-muted-foreground hover:text-foreground\"\n data-active={isActive}\n >\n <span>{label}</span>\n {value === 'unread' && unreadCount > 0 ? (\n <span className=\"inline-flex min-w-4 items-center justify-center rounded-full bg-accent-indigo px-1 text-overline font-medium text-accent-indigo-foreground\">\n {unreadCount > 99 ? '99+' : unreadCount}\n </span>\n ) : null}\n {isActive ? (\n <span\n className=\"absolute bottom-[-14px] left-0 right-0 h-0.5 bg-foreground\"\n aria-hidden=\"true\"\n />\n ) : null}\n </button>\n )\n })}\n </div>\n\n {dismissUndo && onUndoDismiss && (\n <div className=\"border-b bg-muted/50 px-4 py-2 text-sm\">\n <div className=\"flex items-center justify-between gap-3\">\n <span>\n {t('notifications.toast.dismissed', 'Notification dismissed')}\n </span>\n <Button variant=\"ghost\" size=\"sm\" onClick={() => onUndoDismiss()}>\n <RotateCcw className=\"mr-1 h-3 w-3\" />\n {t('notifications.actions.undo', 'Undo')}\n </Button>\n </div>\n </div>\n )}\n\n <div className=\"flex-1 overflow-y-auto overflow-x-hidden overscroll-contain\">\n {filteredNotifications.length === 0 ? (\n <div className=\"flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground\">\n <div className=\"flex size-12 items-center justify-center rounded-full bg-muted/50\">\n <Bell className=\"size-5\" aria-hidden=\"true\" />\n </div>\n <p className=\"text-sm\">{t('notifications.empty', 'No notifications')}</p>\n </div>\n ) : (\n <div className=\"flex flex-col gap-1 p-2\">\n {filteredNotifications.map((notification, idx) => (\n <React.Fragment key={notification.id}>\n {idx > 0 ? <div className=\"my-px h-px bg-border\" aria-hidden=\"true\" /> : null}\n <NotificationItem\n notification={notification}\n onMarkAsRead={() => onMarkAsRead(notification.id)}\n onExecuteAction={(actionId) => onExecuteAction(notification.id, actionId)}\n onDismiss={() => onDismiss(notification.id)}\n t={t}\n customRenderer={customRenderers?.[notification.type]}\n />\n </React.Fragment>\n ))}\n </div>\n )}\n </div>\n\n <div className=\"flex items-center justify-between gap-3 border-t px-5 py-3.5 text-xs leading-4 text-muted-foreground\">\n <div className=\"flex items-center gap-2\">\n <span>{t('notifications.footer.useHint', 'Use')}</span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowUp className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowDown className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span>{t('notifications.footer.toNavigate', 'to navigate')}</span>\n </div>\n <Link\n href=\"/backend/config/notifications\"\n onClick={() => onOpenChange(false)}\n className=\"inline-flex items-center gap-1.5 font-medium text-muted-foreground transition-colors hover:text-foreground\"\n >\n <Settings2 className=\"size-3.5\" aria-hidden=\"true\" />\n {t('notifications.footer.manage', 'Manage notifications')}\n </Link>\n </div>\n </SheetContent>\n </Sheet>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport { Sheet, SheetContent, SheetTitle } from '../../primitives/sheet'\nimport { Tabs, TabsList, TabsTrigger } from '../../primitives/tabs'\nimport { NotificationItem } from './NotificationItem'\nimport type { NotificationDto, NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport type { ComponentType } from 'react'\n\n/**\n * Map of notification type to custom renderer component.\n * Used to provide custom rendering for specific notification types.\n *\n * @example\n * ```tsx\n * const customRenderers = {\n * 'sales.order.created': SalesOrderCreatedRenderer,\n * 'sales.quote.created': SalesQuoteCreatedRenderer,\n * }\n * ```\n */\nexport type NotificationRenderers = Record<string, ComponentType<NotificationRendererProps>>\n\nexport type NotificationPanelProps = {\n open: boolean\n onOpenChange: (open: boolean) => void\n notifications: NotificationDto[]\n unreadCount: number\n onMarkAsRead: (id: string) => Promise<void>\n onExecuteAction: (id: string, actionId: string) => Promise<{ href?: string }>\n onDismiss: (id: string) => Promise<void>\n dismissUndo?: { notification: NotificationDto; previousStatus: 'read' | 'unread' } | null\n onUndoDismiss?: () => Promise<void>\n onMarkAllRead: () => Promise<void>\n t: TranslateFn\n /**\n * Optional map of notification type to custom renderer component.\n * When a notification's type matches a key in this map, the corresponding\n * renderer will be used instead of the default NotificationItem rendering.\n *\n * @example\n * ```tsx\n * import { salesNotificationTypes } from '@open-mercato/core/modules/sales/notifications.client'\n *\n * // Build renderers map from notification types\n * const renderers = Object.fromEntries(\n * salesNotificationTypes\n * .filter(t => t.Renderer)\n * .map(t => [t.type, t.Renderer!])\n * )\n *\n * <NotificationPanel customRenderers={renderers} ... />\n * ```\n */\n customRenderers?: NotificationRenderers\n}\n\nexport function NotificationPanel({\n open,\n onOpenChange,\n notifications,\n unreadCount,\n onMarkAsRead,\n onExecuteAction,\n onDismiss,\n dismissUndo,\n onUndoDismiss,\n onMarkAllRead,\n t,\n customRenderers,\n}: NotificationPanelProps) {\n const [filter, setFilter] = React.useState<'all' | 'unread' | 'action'>('all')\n const [markingAllRead, setMarkingAllRead] = React.useState(false)\n\n const filteredNotifications = React.useMemo(() => {\n switch (filter) {\n case 'unread':\n return notifications.filter((n) => n.status === 'unread')\n case 'action':\n return notifications.filter(\n (n) => n.actions && n.actions.length > 0 && n.status !== 'actioned'\n )\n default:\n return notifications\n }\n }, [notifications, filter])\n\n const handleMarkAllRead = async () => {\n setMarkingAllRead(true)\n try {\n await onMarkAllRead()\n } finally {\n setMarkingAllRead(false)\n }\n }\n\n const handleFilterChange = React.useCallback((next: string) => {\n if (next === 'all' || next === 'unread' || next === 'action') {\n setFilter(next)\n }\n }, [])\n\n // Preserve the body scroll lock contract that consumers (and integration\n // tests) rely on. Radix Dialog locks scroll via react-remove-scroll which\n // does not set `document.body.style.overflow` \u2014 we set it ourselves so the\n // contract is observable.\n React.useEffect(() => {\n if (!open || typeof document === 'undefined') return\n const previous = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n return () => {\n document.body.style.overflow = previous\n }\n }, [open])\n\n return (\n <Sheet open={open} onOpenChange={onOpenChange}>\n <SheetContent\n side=\"right\"\n className=\"gap-0 p-0\"\n hideClose\n aria-label={t('notifications.title', 'Notifications')}\n >\n <div className=\"flex items-center justify-between gap-3 border-b px-5 py-4\">\n <SheetTitle className=\"text-base font-medium leading-6 tracking-tight text-foreground\">\n {t('notifications.title', 'Notifications')}\n </SheetTitle>\n <div className=\"flex items-center gap-1\">\n {unreadCount > 0 ? (\n <Button\n type=\"button\"\n variant=\"link\"\n size=\"sm\"\n onClick={handleMarkAllRead}\n disabled={markingAllRead}\n className=\"gap-1 px-1 text-accent-indigo hover:text-accent-indigo/80 hover:no-underline\"\n >\n {markingAllRead ? <Loader2 className=\"size-3.5 animate-spin\" /> : null}\n {t('notifications.markAllRead', 'Mark all read')}\n </Button>\n ) : null}\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => onOpenChange(false)}\n aria-label={t('notifications.close', 'Close notifications')}\n >\n <X className=\"size-4\" />\n </IconButton>\n </div>\n </div>\n\n <Tabs value={filter} onValueChange={handleFilterChange} variant=\"underline\">\n <TabsList className=\"flex w-full px-5\">\n {(['all', 'unread', 'action'] as const).map((value) => {\n const label =\n value === 'all'\n ? t('notifications.filters.all', 'All')\n : value === 'unread'\n ? t('notifications.filters.unread', 'Unread')\n : t('notifications.filters.actionRequired', 'Action Required')\n const count =\n value === 'unread' && unreadCount > 0\n ? unreadCount > 99\n ? '99+'\n : unreadCount\n : undefined\n return (\n <TabsTrigger key={value} value={value} count={count}>\n {label}\n </TabsTrigger>\n )\n })}\n </TabsList>\n </Tabs>\n\n {dismissUndo && onUndoDismiss && (\n <div className=\"border-b bg-muted/50 px-4 py-2 text-sm\">\n <div className=\"flex items-center justify-between gap-3\">\n <span>\n {t('notifications.toast.dismissed', 'Notification dismissed')}\n </span>\n <Button variant=\"ghost\" size=\"sm\" onClick={() => onUndoDismiss()}>\n <RotateCcw className=\"mr-1 h-3 w-3\" />\n {t('notifications.actions.undo', 'Undo')}\n </Button>\n </div>\n </div>\n )}\n\n <div className=\"flex-1 overflow-y-auto overflow-x-hidden overscroll-contain\">\n {filteredNotifications.length === 0 ? (\n <div className=\"flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground\">\n <div className=\"flex size-12 items-center justify-center rounded-full bg-muted/50\">\n <Bell className=\"size-5\" aria-hidden=\"true\" />\n </div>\n <p className=\"text-sm\">{t('notifications.empty', 'No notifications')}</p>\n </div>\n ) : (\n <div className=\"flex flex-col gap-1 p-2\">\n {filteredNotifications.map((notification, idx) => (\n <React.Fragment key={notification.id}>\n {idx > 0 ? <div className=\"my-px h-px bg-border\" aria-hidden=\"true\" /> : null}\n <NotificationItem\n notification={notification}\n onMarkAsRead={() => onMarkAsRead(notification.id)}\n onExecuteAction={(actionId) => onExecuteAction(notification.id, actionId)}\n onDismiss={() => onDismiss(notification.id)}\n t={t}\n customRenderer={customRenderers?.[notification.type]}\n />\n </React.Fragment>\n ))}\n </div>\n )}\n </div>\n\n <div className=\"flex items-center justify-between gap-3 border-t px-5 py-3.5 text-xs leading-4 text-muted-foreground\">\n <div className=\"flex items-center gap-2\">\n <span>{t('notifications.footer.useHint', 'Use')}</span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowUp className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowDown className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span>{t('notifications.footer.toNavigate', 'to navigate')}</span>\n </div>\n <Link\n href=\"/backend/config/notifications\"\n onClick={() => onOpenChange(false)}\n className=\"inline-flex items-center gap-1.5 font-medium text-muted-foreground transition-colors hover:text-foreground\"\n >\n <Settings2 className=\"size-3.5\" aria-hidden=\"true\" />\n {t('notifications.footer.manage', 'Manage notifications')}\n </Link>\n </div>\n </SheetContent>\n </Sheet>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6330.1.a261878aa8",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,13 +155,13 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.6-develop.6330.1.a261878aa8",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
164
|
+
"@open-mercato/shared": "0.6.6-develop.6330.1.a261878aa8",
|
|
165
165
|
"@testing-library/dom": "^10.4.1",
|
|
166
166
|
"@testing-library/jest-dom": "^6.9.1",
|
|
167
167
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/AppShell.tsx
CHANGED
|
@@ -29,6 +29,7 @@ import { dismissRecordConflict } from './conflicts/store'
|
|
|
29
29
|
import { ProgressTopBar } from './progress/ProgressTopBar'
|
|
30
30
|
import { UpgradeActionBanner } from './upgrades/UpgradeActionBanner'
|
|
31
31
|
import { PartialIndexBanner } from './indexes/PartialIndexBanner'
|
|
32
|
+
import { OrganizationScopeBoundary } from './OrganizationScopeBoundary'
|
|
32
33
|
import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
|
|
33
34
|
import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
|
|
34
35
|
import { readVersionedPreference, writeVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
|
|
@@ -1399,7 +1400,9 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1399
1400
|
context={injectionContext}
|
|
1400
1401
|
/>
|
|
1401
1402
|
<div id="om-top-banners" className="mb-3 space-y-2" />
|
|
1402
|
-
{
|
|
1403
|
+
<OrganizationScopeBoundary active={isOnSettingsPath}>
|
|
1404
|
+
{children}
|
|
1405
|
+
</OrganizationScopeBoundary>
|
|
1403
1406
|
<InjectionSpot spotId={BACKEND_LAYOUT_FOOTER_INJECTION_SPOT_ID} context={injectionContext} />
|
|
1404
1407
|
</main>
|
|
1405
1408
|
<footer className="border-t bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/80 px-4 py-3 flex flex-wrap items-center justify-end gap-4">
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
} from 'lucide-react'
|
|
74
74
|
import { loadGeneratedFieldRegistrations } from './fields/registry'
|
|
75
75
|
import type { CustomFieldDefDto, CustomFieldDefinitionsPayload, CustomFieldsetDto } from './utils/customFieldDefs'
|
|
76
|
+
import { isDefVisible } from './utils/customFieldDefs'
|
|
76
77
|
import { buildFormFieldsFromCustomFields, buildFormFieldFromCustomFieldDef } from './utils/customFieldForms'
|
|
77
78
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
78
79
|
import { TagsInput } from './inputs/TagsInput'
|
|
@@ -1553,7 +1554,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
|
|
|
1553
1554
|
fieldsetGroupMap.set(group.code, { code: group.code, title: group.title, hint: group.hint })
|
|
1554
1555
|
})
|
|
1555
1556
|
}
|
|
1556
|
-
const sortedDefs = [...defList]
|
|
1557
|
+
const sortedDefs = [...defList]
|
|
1558
|
+
.filter((definition) => isDefVisible(definition, 'form'))
|
|
1559
|
+
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
|
|
1557
1560
|
const ensureBucket = (code: string | null, def: CustomFieldDefDto): CustomFieldGroupLayout => {
|
|
1558
1561
|
const key = code ?? '__default__'
|
|
1559
1562
|
let bucket = groupsMap.get(key)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import {
|
|
4
|
+
subscribeOrganizationScopeChanged,
|
|
5
|
+
type OrganizationScopeChangedDetail,
|
|
6
|
+
} from '@open-mercato/shared/lib/frontend/organizationEvents'
|
|
7
|
+
|
|
8
|
+
export type OrganizationScopeBoundaryProps = {
|
|
9
|
+
active: boolean
|
|
10
|
+
children: React.ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Remounts its children whenever the active organization/tenant scope changes.
|
|
15
|
+
*
|
|
16
|
+
* The org/tenant switcher already calls `router.refresh()` on a scope change,
|
|
17
|
+
* which re-runs server components but does NOT remount client components — so
|
|
18
|
+
* settings pages that fetch their data once in a mount `useEffect` keep showing
|
|
19
|
+
* the previous scope's values until a manual reload. Wrapping those pages here
|
|
20
|
+
* forces a fresh mount (and therefore a refetch) on a real scope change.
|
|
21
|
+
*
|
|
22
|
+
* Gated by `active` so only the settings surface remounts; list/CRUD pages keep
|
|
23
|
+
* relying on the smoother `router.refresh()` path that preserves table state.
|
|
24
|
+
*/
|
|
25
|
+
export function OrganizationScopeBoundary({ active, children }: OrganizationScopeBoundaryProps) {
|
|
26
|
+
const [scopeKey, setScopeKey] = React.useState(0)
|
|
27
|
+
const lastScopeRef = React.useRef<OrganizationScopeChangedDetail | null>(null)
|
|
28
|
+
const hasInitializedScopeRef = React.useRef(false)
|
|
29
|
+
|
|
30
|
+
React.useEffect(() => {
|
|
31
|
+
return subscribeOrganizationScopeChanged((detail) => {
|
|
32
|
+
const prev = lastScopeRef.current
|
|
33
|
+
lastScopeRef.current = detail
|
|
34
|
+
if (!hasInitializedScopeRef.current) {
|
|
35
|
+
hasInitializedScopeRef.current = true
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
prev &&
|
|
40
|
+
prev.organizationId === detail.organizationId &&
|
|
41
|
+
prev.tenantId === detail.tenantId
|
|
42
|
+
) {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
setScopeKey((current) => current + 1)
|
|
46
|
+
})
|
|
47
|
+
}, [])
|
|
48
|
+
|
|
49
|
+
if (!active) {
|
|
50
|
+
return <>{children}</>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return <React.Fragment key={scopeKey}>{children}</React.Fragment>
|
|
54
|
+
}
|
|
@@ -275,6 +275,65 @@ describe('CrudForm custom field loading', () => {
|
|
|
275
275
|
expect(container.querySelector('[data-crud-field-id="cf_renewal_quarter"]')?.textContent).toContain('Q3')
|
|
276
276
|
})
|
|
277
277
|
|
|
278
|
+
it('omits custom fields with formEditable:false from the generated custom-fields section', async () => {
|
|
279
|
+
buildFormFieldFromCustomFieldDefMock.mockImplementation((definition: any) => ({
|
|
280
|
+
id: `cf_${definition.key}`,
|
|
281
|
+
label: definition.label ?? definition.key,
|
|
282
|
+
type: 'text',
|
|
283
|
+
}))
|
|
284
|
+
fetchCustomFieldFormStructureMock.mockResolvedValue({
|
|
285
|
+
fields: [],
|
|
286
|
+
definitions: [
|
|
287
|
+
{
|
|
288
|
+
entityId: 'customers:customer_company_profile',
|
|
289
|
+
key: 'editable_note',
|
|
290
|
+
label: 'Editable note',
|
|
291
|
+
kind: 'text',
|
|
292
|
+
formEditable: true,
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
entityId: 'customers:customer_company_profile',
|
|
296
|
+
key: 'readonly_note',
|
|
297
|
+
label: 'Readonly note',
|
|
298
|
+
kind: 'text',
|
|
299
|
+
formEditable: false,
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
metadata: {
|
|
303
|
+
items: [],
|
|
304
|
+
fieldsetsByEntity: {},
|
|
305
|
+
entitySettings: {},
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const { container } = renderWithProviders(
|
|
310
|
+
<CrudForm
|
|
311
|
+
embedded
|
|
312
|
+
title="Form"
|
|
313
|
+
entityId="customers:customer_company_profile"
|
|
314
|
+
fields={fields}
|
|
315
|
+
groups={groups}
|
|
316
|
+
initialValues={{ name: 'Acme', editable_note: 'x', readonly_note: 'y' }}
|
|
317
|
+
onSubmit={() => {}}
|
|
318
|
+
/>,
|
|
319
|
+
{
|
|
320
|
+
dict: {
|
|
321
|
+
'ui.forms.actions.save': 'Save',
|
|
322
|
+
'entities.customFields.manageFieldset': 'Manage fields',
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
await waitFor(() => {
|
|
328
|
+
expect(container.querySelector('[data-crud-field-id="cf_editable_note"]')).not.toBeNull()
|
|
329
|
+
})
|
|
330
|
+
expect(container.querySelector('[data-crud-field-id="cf_readonly_note"]')).toBeNull()
|
|
331
|
+
expect(buildFormFieldFromCustomFieldDefMock).not.toHaveBeenCalledWith(
|
|
332
|
+
expect.objectContaining({ key: 'readonly_note' }),
|
|
333
|
+
expect.anything(),
|
|
334
|
+
)
|
|
335
|
+
})
|
|
336
|
+
|
|
278
337
|
it('submits custom entity values without loaded record metadata', async () => {
|
|
279
338
|
const handleSubmit = jest.fn().mockResolvedValue(undefined)
|
|
280
339
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { act, render } from '@testing-library/react'
|
|
7
|
+
import { emitOrganizationScopeChanged } from '@open-mercato/shared/lib/frontend/organizationEvents'
|
|
8
|
+
import { OrganizationScopeBoundary } from '../OrganizationScopeBoundary'
|
|
9
|
+
|
|
10
|
+
let mountCount = 0
|
|
11
|
+
|
|
12
|
+
function MountCounter() {
|
|
13
|
+
React.useEffect(() => {
|
|
14
|
+
mountCount += 1
|
|
15
|
+
}, [])
|
|
16
|
+
return <span data-testid="child">child</span>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('<OrganizationScopeBoundary>', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mountCount = 0
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('remounts children on a real scope change when active', () => {
|
|
25
|
+
render(
|
|
26
|
+
<OrganizationScopeBoundary active>
|
|
27
|
+
<MountCounter />
|
|
28
|
+
</OrganizationScopeBoundary>,
|
|
29
|
+
)
|
|
30
|
+
expect(mountCount).toBe(1)
|
|
31
|
+
|
|
32
|
+
// First scope event after mount only establishes the baseline.
|
|
33
|
+
act(() => {
|
|
34
|
+
emitOrganizationScopeChanged({ organizationId: 'org-a', tenantId: 'tenant-a' })
|
|
35
|
+
})
|
|
36
|
+
expect(mountCount).toBe(1)
|
|
37
|
+
|
|
38
|
+
// A genuine change remounts the subtree, re-running mount effects.
|
|
39
|
+
act(() => {
|
|
40
|
+
emitOrganizationScopeChanged({ organizationId: 'org-b', tenantId: 'tenant-a' })
|
|
41
|
+
})
|
|
42
|
+
expect(mountCount).toBe(2)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does not remount children when the scope is unchanged', () => {
|
|
46
|
+
render(
|
|
47
|
+
<OrganizationScopeBoundary active>
|
|
48
|
+
<MountCounter />
|
|
49
|
+
</OrganizationScopeBoundary>,
|
|
50
|
+
)
|
|
51
|
+
act(() => {
|
|
52
|
+
emitOrganizationScopeChanged({ organizationId: 'org-c', tenantId: 'tenant-c' })
|
|
53
|
+
})
|
|
54
|
+
act(() => {
|
|
55
|
+
emitOrganizationScopeChanged({ organizationId: 'org-c', tenantId: 'tenant-c' })
|
|
56
|
+
})
|
|
57
|
+
expect(mountCount).toBe(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('does not remount children on a scope change when inactive', () => {
|
|
61
|
+
render(
|
|
62
|
+
<OrganizationScopeBoundary active={false}>
|
|
63
|
+
<MountCounter />
|
|
64
|
+
</OrganizationScopeBoundary>,
|
|
65
|
+
)
|
|
66
|
+
act(() => {
|
|
67
|
+
emitOrganizationScopeChanged({ organizationId: 'org-d', tenantId: 'tenant-d' })
|
|
68
|
+
})
|
|
69
|
+
act(() => {
|
|
70
|
+
emitOrganizationScopeChanged({ organizationId: 'org-e', tenantId: 'tenant-e' })
|
|
71
|
+
})
|
|
72
|
+
expect(mountCount).toBe(1)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -5,6 +5,7 @@ import { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from 'luci
|
|
|
5
5
|
import { Button } from '../../primitives/button'
|
|
6
6
|
import { IconButton } from '../../primitives/icon-button'
|
|
7
7
|
import { Sheet, SheetContent, SheetTitle } from '../../primitives/sheet'
|
|
8
|
+
import { Tabs, TabsList, TabsTrigger } from '../../primitives/tabs'
|
|
8
9
|
import { NotificationItem } from './NotificationItem'
|
|
9
10
|
import type { NotificationDto, NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'
|
|
10
11
|
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -97,6 +98,12 @@ export function NotificationPanel({
|
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
const handleFilterChange = React.useCallback((next: string) => {
|
|
102
|
+
if (next === 'all' || next === 'unread' || next === 'action') {
|
|
103
|
+
setFilter(next)
|
|
104
|
+
}
|
|
105
|
+
}, [])
|
|
106
|
+
|
|
100
107
|
// Preserve the body scroll lock contract that consumers (and integration
|
|
101
108
|
// tests) rely on. Radix Dialog locks scroll via react-remove-scroll which
|
|
102
109
|
// does not set `document.body.style.overflow` — we set it ourselves so the
|
|
@@ -124,15 +131,17 @@ export function NotificationPanel({
|
|
|
124
131
|
</SheetTitle>
|
|
125
132
|
<div className="flex items-center gap-1">
|
|
126
133
|
{unreadCount > 0 ? (
|
|
127
|
-
<
|
|
134
|
+
<Button
|
|
128
135
|
type="button"
|
|
136
|
+
variant="link"
|
|
137
|
+
size="sm"
|
|
129
138
|
onClick={handleMarkAllRead}
|
|
130
139
|
disabled={markingAllRead}
|
|
131
|
-
className="
|
|
140
|
+
className="gap-1 px-1 text-accent-indigo hover:text-accent-indigo/80 hover:no-underline"
|
|
132
141
|
>
|
|
133
142
|
{markingAllRead ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
|
134
143
|
{t('notifications.markAllRead', 'Mark all read')}
|
|
135
|
-
</
|
|
144
|
+
</Button>
|
|
136
145
|
) : null}
|
|
137
146
|
<IconButton
|
|
138
147
|
type="button"
|
|
@@ -146,41 +155,29 @@ export function NotificationPanel({
|
|
|
146
155
|
</div>
|
|
147
156
|
</div>
|
|
148
157
|
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
</span>
|
|
173
|
-
) : null}
|
|
174
|
-
{isActive ? (
|
|
175
|
-
<span
|
|
176
|
-
className="absolute bottom-[-14px] left-0 right-0 h-0.5 bg-foreground"
|
|
177
|
-
aria-hidden="true"
|
|
178
|
-
/>
|
|
179
|
-
) : null}
|
|
180
|
-
</button>
|
|
181
|
-
)
|
|
182
|
-
})}
|
|
183
|
-
</div>
|
|
158
|
+
<Tabs value={filter} onValueChange={handleFilterChange} variant="underline">
|
|
159
|
+
<TabsList className="flex w-full px-5">
|
|
160
|
+
{(['all', 'unread', 'action'] as const).map((value) => {
|
|
161
|
+
const label =
|
|
162
|
+
value === 'all'
|
|
163
|
+
? t('notifications.filters.all', 'All')
|
|
164
|
+
: value === 'unread'
|
|
165
|
+
? t('notifications.filters.unread', 'Unread')
|
|
166
|
+
: t('notifications.filters.actionRequired', 'Action Required')
|
|
167
|
+
const count =
|
|
168
|
+
value === 'unread' && unreadCount > 0
|
|
169
|
+
? unreadCount > 99
|
|
170
|
+
? '99+'
|
|
171
|
+
: unreadCount
|
|
172
|
+
: undefined
|
|
173
|
+
return (
|
|
174
|
+
<TabsTrigger key={value} value={value} count={count}>
|
|
175
|
+
{label}
|
|
176
|
+
</TabsTrigger>
|
|
177
|
+
)
|
|
178
|
+
})}
|
|
179
|
+
</TabsList>
|
|
180
|
+
</Tabs>
|
|
184
181
|
|
|
185
182
|
{dismissUndo && onUndoDismiss && (
|
|
186
183
|
<div className="border-b bg-muted/50 px-4 py-2 text-sm">
|