@open-mercato/ui 0.6.5-develop.5337.1.534b781eac → 0.6.5

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.
Files changed (65) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/AiChat.js.map +1 -1
  3. package/dist/ai/records/ActivityCard.js +1 -6
  4. package/dist/ai/records/ActivityCard.js.map +2 -2
  5. package/dist/ai/records/DealCard.js +2 -23
  6. package/dist/ai/records/DealCard.js.map +2 -2
  7. package/dist/ai/records/ProductCard.js +2 -17
  8. package/dist/ai/records/ProductCard.js.map +2 -2
  9. package/dist/ai/useAiChat.js.map +1 -1
  10. package/dist/backend/AppShell.js +15 -8
  11. package/dist/backend/AppShell.js.map +2 -2
  12. package/dist/backend/CrudForm.js.map +1 -1
  13. package/dist/backend/DataTable.js +10 -9
  14. package/dist/backend/DataTable.js.map +2 -2
  15. package/dist/backend/ProfileDropdown.js +62 -33
  16. package/dist/backend/ProfileDropdown.js.map +2 -2
  17. package/dist/backend/charts/KpiCard.js +22 -14
  18. package/dist/backend/charts/KpiCard.js.map +2 -2
  19. package/dist/backend/charts/Sparkline.js +75 -0
  20. package/dist/backend/charts/Sparkline.js.map +7 -0
  21. package/dist/backend/charts/index.js +4 -1
  22. package/dist/backend/charts/index.js.map +2 -2
  23. package/dist/backend/dashboard/DashboardScreen.js +1 -2
  24. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  25. package/dist/backend/injection/InjectionSpot.js +23 -6
  26. package/dist/backend/injection/InjectionSpot.js.map +2 -2
  27. package/dist/backend/injection/useGuardedMutation.js.map +1 -1
  28. package/dist/backend/utils/optimisticLock.js.map +1 -1
  29. package/dist/primitives/avatar.js +2 -2
  30. package/dist/primitives/avatar.js.map +2 -2
  31. package/dist/primitives/pagination.js +3 -3
  32. package/dist/primitives/pagination.js.map +2 -2
  33. package/dist/utils/format.js +27 -0
  34. package/dist/utils/format.js.map +7 -0
  35. package/package.json +9 -6
  36. package/src/ai/AiChat.tsx +1 -1
  37. package/src/ai/__tests__/AiChat.task-plan.test.tsx +1 -1
  38. package/src/ai/records/ActivityCard.tsx +1 -7
  39. package/src/ai/records/DealCard.tsx +2 -26
  40. package/src/ai/records/ProductCard.tsx +2 -19
  41. package/src/ai/useAiChat.ts +1 -1
  42. package/src/backend/AppShell.tsx +18 -8
  43. package/src/backend/CrudForm.tsx +1 -1
  44. package/src/backend/DataTable.tsx +18 -11
  45. package/src/backend/ProfileDropdown.tsx +35 -3
  46. package/src/backend/__tests__/AppShell.test.tsx +61 -1
  47. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +52 -0
  48. package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +99 -0
  49. package/src/backend/__tests__/ProfileDropdown.test.tsx +59 -0
  50. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +8 -0
  51. package/src/backend/charts/KpiCard.tsx +22 -12
  52. package/src/backend/charts/Sparkline.tsx +87 -0
  53. package/src/backend/charts/index.ts +2 -1
  54. package/src/backend/dashboard/DashboardScreen.tsx +1 -2
  55. package/src/backend/injection/InjectionSpot.tsx +27 -7
  56. package/src/backend/injection/__tests__/InjectionSpot.remount.test.tsx +140 -0
  57. package/src/backend/injection/useGuardedMutation.ts +1 -1
  58. package/src/backend/utils/optimisticLock.ts +1 -1
  59. package/src/primitives/__tests__/alert.test.tsx +16 -0
  60. package/src/primitives/__tests__/avatar.test.tsx +36 -1
  61. package/src/primitives/__tests__/pagination.test.tsx +31 -0
  62. package/src/primitives/avatar.tsx +8 -2
  63. package/src/primitives/pagination.tsx +3 -3
  64. package/src/utils/__tests__/format.test.ts +58 -0
  65. package/src/utils/format.ts +40 -0
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
+ import { createPortal } from "react-dom";
4
5
  import Link from "next/link";
5
6
  import { User, LogOut, Bell, Moon, Sun, Globe, Key, Check, ChevronRight } from "lucide-react";
6
7
  import { useT, useLocale } from "@open-mercato/shared/lib/i18n/context";
@@ -14,6 +15,7 @@ import { mergeMenuItems } from "./injection/mergeMenuItems.js";
14
15
  import { resolveInjectedIcon } from "./injection/resolveInjectedIcon.js";
15
16
  import { InjectionSpot } from "./injection/InjectionSpot.js";
16
17
  import { BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID } from "./injection/spotIds.js";
18
+ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
17
19
  const localeLabels = {
18
20
  en: "English",
19
21
  de: "Deutsch",
@@ -34,10 +36,33 @@ function ProfileDropdown({
34
36
  const [mounted, setMounted] = React.useState(false);
35
37
  const buttonRef = React.useRef(null);
36
38
  const menuRef = React.useRef(null);
39
+ const [menuPosition, setMenuPosition] = React.useState(null);
37
40
  const { items: injectedItems } = useInjectedMenuItems("menu:topbar:profile-dropdown");
38
41
  React.useEffect(() => {
39
42
  setMounted(true);
40
43
  }, []);
44
+ useIsomorphicLayoutEffect(() => {
45
+ if (!open) {
46
+ setMenuPosition(null);
47
+ return;
48
+ }
49
+ const updatePosition = () => {
50
+ const trigger = buttonRef.current;
51
+ if (!trigger) return;
52
+ const rect = trigger.getBoundingClientRect();
53
+ setMenuPosition({
54
+ top: rect.bottom + 8,
55
+ right: Math.max(0, window.innerWidth - rect.right)
56
+ });
57
+ };
58
+ updatePosition();
59
+ window.addEventListener("resize", updatePosition);
60
+ window.addEventListener("scroll", updatePosition, true);
61
+ return () => {
62
+ window.removeEventListener("resize", updatePosition);
63
+ window.removeEventListener("scroll", updatePosition, true);
64
+ };
65
+ }, [open]);
41
66
  const isDark = resolvedTheme === "dark";
42
67
  React.useEffect(() => {
43
68
  if (!open) return;
@@ -312,40 +337,44 @@ function ProfileDropdown({
312
337
  children: /* @__PURE__ */ jsx(User, { className: "size-4" })
313
338
  }
314
339
  ),
315
- open && /* @__PURE__ */ jsxs(
316
- "div",
317
- {
318
- ref: menuRef,
319
- className: "absolute right-0 top-full z-popover mt-2 w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg",
320
- role: "menu",
321
- "data-testid": "profile-dropdown",
322
- children: [
323
- (displayName || email) && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 border-b bg-muted/30 px-3 py-3", children: [
324
- /* @__PURE__ */ jsx("div", { className: "flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-indigo/10 text-accent-indigo", children: /* @__PURE__ */ jsx(User, { className: "size-4", "aria-hidden": "true" }) }),
325
- /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [
326
- displayName ? /* @__PURE__ */ jsx("span", { className: "truncate text-sm font-medium leading-5 text-foreground", children: displayName }) : null,
327
- email ? /* @__PURE__ */ jsx("span", { className: cn(
328
- "truncate text-xs leading-4",
329
- displayName ? "text-muted-foreground" : "text-foreground font-medium"
330
- ), children: email }) : null,
331
- !displayName && email ? /* @__PURE__ */ jsx("span", { className: "truncate text-overline uppercase tracking-wider text-muted-foreground/80", children: t("ui.userMenu.loggedInAs", "Logged in") }) : null
340
+ open && menuPosition && typeof document !== "undefined" && createPortal(
341
+ /* @__PURE__ */ jsxs(
342
+ "div",
343
+ {
344
+ ref: menuRef,
345
+ className: "fixed z-popover w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg",
346
+ style: { top: menuPosition.top, right: menuPosition.right },
347
+ role: "menu",
348
+ "data-testid": "profile-dropdown",
349
+ children: [
350
+ (displayName || email) && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 border-b bg-muted/30 px-3 py-3", children: [
351
+ /* @__PURE__ */ jsx("div", { className: "flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-indigo/10 text-accent-indigo", children: /* @__PURE__ */ jsx(User, { className: "size-4", "aria-hidden": "true" }) }),
352
+ /* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [
353
+ displayName ? /* @__PURE__ */ jsx("span", { className: "truncate text-sm font-medium leading-5 text-foreground", children: displayName }) : null,
354
+ email ? /* @__PURE__ */ jsx("span", { className: cn(
355
+ "truncate text-xs leading-4",
356
+ displayName ? "text-muted-foreground" : "text-foreground font-medium"
357
+ ), children: email }) : null,
358
+ !displayName && email ? /* @__PURE__ */ jsx("span", { className: "truncate text-overline uppercase tracking-wider text-muted-foreground/80", children: t("ui.userMenu.loggedInAs", "Logged in") }) : null
359
+ ] })
360
+ ] }),
361
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col p-1.5", children: [
362
+ mergedMenuItems.map((item) => /* @__PURE__ */ jsxs(React.Fragment, { children: [
363
+ item.separator ? /* @__PURE__ */ jsx("div", { className: "my-1 h-px bg-border", "aria-hidden": "true" }) : null,
364
+ item.source === "injected" ? item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null : renderBuiltInItem(item.id)
365
+ ] }, item.id)),
366
+ /* @__PURE__ */ jsx(
367
+ InjectionSpot,
368
+ {
369
+ spotId: BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID,
370
+ context: injectionContext
371
+ }
372
+ )
332
373
  ] })
333
- ] }),
334
- /* @__PURE__ */ jsxs("div", { className: "flex flex-col p-1.5", children: [
335
- mergedMenuItems.map((item) => /* @__PURE__ */ jsxs(React.Fragment, { children: [
336
- item.separator ? /* @__PURE__ */ jsx("div", { className: "my-1 h-px bg-border", "aria-hidden": "true" }) : null,
337
- item.source === "injected" ? item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null : renderBuiltInItem(item.id)
338
- ] }, item.id)),
339
- /* @__PURE__ */ jsx(
340
- InjectionSpot,
341
- {
342
- spotId: BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID,
343
- context: injectionContext
344
- }
345
- )
346
- ] })
347
- ]
348
- }
374
+ ]
375
+ }
376
+ ),
377
+ document.body
349
378
  )
350
379
  ] });
351
380
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/backend/ProfileDropdown.tsx"],
4
- "sourcesContent": ["'use client'\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { User, LogOut, Bell, Moon, Sun, Globe, Key, Check, ChevronRight } from 'lucide-react'\nimport { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'\nimport { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'\nimport { useTheme } from '@open-mercato/ui/theme'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { IconButton } from '../primitives/icon-button'\nimport { Switch } from '../primitives/switch'\nimport { useInjectedMenuItems } from './injection/useInjectedMenuItems'\nimport { mergeMenuItems, type MergedMenuItem } from './injection/mergeMenuItems'\nimport { resolveInjectedIcon } from './injection/resolveInjectedIcon'\nimport { InjectionSpot } from './injection/InjectionSpot'\nimport { BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID } from './injection/spotIds'\n\nexport type ProfileDropdownProps = {\n email?: string\n displayName?: string\n changePasswordHref?: string\n notificationsHref?: string\n}\n\nconst localeLabels: Record<Locale, string> = {\n en: 'English',\n de: 'Deutsch',\n es: 'Espa\u00F1ol',\n pl: 'Polski',\n}\n\nexport function ProfileDropdown({\n email,\n displayName,\n changePasswordHref = '/backend/profile/change-password',\n notificationsHref,\n}: ProfileDropdownProps) {\n const t = useT()\n const currentLocale = useLocale()\n const { resolvedTheme, setTheme } = useTheme()\n const [open, setOpen] = React.useState(false)\n const [languageOpen, setLanguageOpen] = React.useState(false)\n const [mounted, setMounted] = React.useState(false)\n const buttonRef = React.useRef<HTMLButtonElement>(null)\n const menuRef = React.useRef<HTMLDivElement>(null)\n const { items: injectedItems } = useInjectedMenuItems('menu:topbar:profile-dropdown')\n\n React.useEffect(() => {\n setMounted(true)\n }, [])\n\n const isDark = resolvedTheme === 'dark'\n\n // Close on click outside\n React.useEffect(() => {\n if (!open) return\n function handleClick(event: MouseEvent) {\n if (\n menuRef.current &&\n !menuRef.current.contains(event.target as Node) &&\n buttonRef.current &&\n !buttonRef.current.contains(event.target as Node)\n ) {\n setOpen(false)\n setLanguageOpen(false)\n }\n }\n document.addEventListener('mousedown', handleClick)\n return () => document.removeEventListener('mousedown', handleClick)\n }, [open])\n\n // Close on Escape\n React.useEffect(() => {\n if (!open) return\n function handleKeyDown(event: KeyboardEvent) {\n if (event.key === 'Escape') {\n if (languageOpen) {\n setLanguageOpen(false)\n } else {\n setOpen(false)\n buttonRef.current?.focus()\n }\n }\n }\n document.addEventListener('keydown', handleKeyDown)\n return () => document.removeEventListener('keydown', handleKeyDown)\n }, [open, languageOpen])\n\n const handleThemeToggle = () => {\n setTheme(isDark ? 'light' : 'dark')\n }\n\n const handleLocaleChange = async (locale: Locale) => {\n try {\n await fetch('/api/auth/locale', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ locale }),\n })\n window.location.reload()\n } catch {}\n }\n\n // Unified row class \u2014 every menu item uses this for perfectly aligned layout.\n // h-9 keeps all rows the same height regardless of whether they have a trailing element.\n const menuItemClass = cn(\n 'group flex h-9 w-full items-center gap-3 rounded-md px-2.5 text-sm text-foreground',\n 'transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',\n 'cursor-pointer',\n )\n const menuIconClass = 'size-4 shrink-0 text-muted-foreground group-hover:text-foreground'\n\n const resolveMenuLabel = React.useCallback(\n (item: Pick<MergedMenuItem, 'id' | 'label' | 'labelKey'>): string => {\n if (item.labelKey && item.label) return t(item.labelKey, item.label)\n if (item.labelKey) return t(item.labelKey, item.id)\n if (item.label && item.label.includes('.')) return t(item.label, item.id)\n return item.label ?? item.id\n },\n [t],\n )\n\n const builtInMenuItems = React.useMemo(\n () => {\n const items: Array<{ id: string; separator?: boolean }> = [{ id: 'change-password' }]\n if (notificationsHref) items.push({ id: 'notifications' })\n items.push({ id: 'theme-toggle', separator: true }, { id: 'language' }, { id: 'sign-out', separator: true })\n return items\n },\n [notificationsHref],\n )\n\n const mergedMenuItems = React.useMemo(\n () => mergeMenuItems(builtInMenuItems, injectedItems),\n [builtInMenuItems, injectedItems],\n )\n const injectionContext = React.useMemo(\n () => ({\n email,\n displayName,\n locale: currentLocale,\n }),\n [currentLocale, displayName, email],\n )\n\n const renderInjectedItem = React.useCallback(\n (item: MergedMenuItem) => {\n const label = resolveMenuLabel(item)\n const icon = resolveInjectedIcon(item.icon)\n const inner = (\n <>\n <span className={menuIconClass}>{icon}</span>\n <span className=\"flex-1 truncate\">{label}</span>\n </>\n )\n if (item.href) {\n return (\n <Link\n key={item.id}\n href={item.href}\n className={menuItemClass}\n role=\"menuitem\"\n data-menu-item-id={item.id}\n onClick={() => setOpen(false)}\n >\n {inner}\n </Link>\n )\n }\n return (\n <button\n key={item.id}\n type=\"button\"\n className={menuItemClass}\n role=\"menuitem\"\n data-menu-item-id={item.id}\n onClick={() => {\n item.onClick?.()\n setOpen(false)\n }}\n >\n {inner}\n </button>\n )\n },\n [menuItemClass, menuIconClass, resolveMenuLabel],\n )\n\n const renderBuiltInItem = React.useCallback(\n (id: string) => {\n if (id === 'change-password') {\n return (\n <Link\n key={id}\n href={changePasswordHref}\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n >\n <Key className={menuIconClass} />\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.changePassword', 'Change Password')}</span>\n </Link>\n )\n }\n\n if (id === 'notifications' && notificationsHref) {\n return (\n <Link\n key={id}\n href={notificationsHref}\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n >\n <Bell className={menuIconClass} />\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.notifications', 'Notification Preferences')}</span>\n </Link>\n )\n }\n\n if (id === 'theme-toggle') {\n if (!mounted) return null\n return (\n <div\n key={id}\n className={cn(menuItemClass, 'justify-between')}\n role=\"menuitem\"\n onClick={handleThemeToggle}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n handleThemeToggle()\n }\n }}\n tabIndex={0}\n >\n <span className=\"flex flex-1 items-center gap-3\">\n {isDark ? <Moon className={menuIconClass} /> : <Sun className={menuIconClass} />}\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.theme', 'Theme')}</span>\n </span>\n <Switch\n checked={isDark}\n onCheckedChange={handleThemeToggle}\n aria-label={t('ui.profileMenu.theme', 'Theme')}\n onClick={(e) => e.stopPropagation()}\n />\n </div>\n )\n }\n\n if (id === 'language') {\n return (\n <div key={id} className=\"contents\">\n <button\n type=\"button\"\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setLanguageOpen(!languageOpen)}\n aria-expanded={languageOpen}\n >\n <Globe className={menuIconClass} />\n <span className=\"truncate\">{t('ui.profileMenu.language', 'Language')}</span>\n <span className=\"text-xs text-muted-foreground\">{localeLabels[currentLocale]}</span>\n <ChevronRight\n className={cn(\n 'ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform',\n languageOpen && 'rotate-90',\n )}\n aria-hidden=\"true\"\n />\n </button>\n {languageOpen && (\n <div className=\"ml-7 mr-1 flex flex-col gap-0.5 border-l pl-2 py-1\">\n {locales.map((locale) => (\n <button\n key={locale}\n type=\"button\"\n className={cn(\n 'flex h-8 w-full items-center justify-between gap-2 rounded-md px-2 text-sm transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',\n locale === currentLocale ? 'font-medium text-foreground' : 'text-muted-foreground',\n )}\n onClick={() => handleLocaleChange(locale)}\n >\n <span className=\"truncate\">{localeLabels[locale]}</span>\n {locale === currentLocale && <Check className=\"size-3.5 shrink-0 text-accent-indigo\" aria-hidden=\"true\" />}\n </button>\n ))}\n </div>\n )}\n </div>\n )\n }\n\n if (id === 'sign-out') {\n return (\n <form key={id} action=\"/api/auth/logout\" method=\"POST\">\n <button\n type=\"submit\"\n className={menuItemClass}\n role=\"menuitem\"\n >\n <LogOut className={menuIconClass} />\n <span className=\"flex-1 truncate text-left\">{t('ui.userMenu.logout', 'Sign Out')}</span>\n </button>\n </form>\n )\n }\n\n return null\n },\n [\n changePasswordHref,\n currentLocale,\n handleThemeToggle,\n isDark,\n languageOpen,\n menuItemClass,\n menuIconClass,\n mounted,\n notificationsHref,\n t,\n ],\n )\n\n return (\n <div className=\"relative\">\n <IconButton\n ref={buttonRef}\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setOpen(!open)}\n aria-expanded={open}\n aria-haspopup=\"menu\"\n data-testid=\"profile-dropdown-trigger\"\n title={email || t('ui.userMenu.userFallback', 'User')}\n >\n <User className=\"size-4\" />\n </IconButton>\n\n {open && (\n <div\n ref={menuRef}\n className=\"absolute right-0 top-full z-popover mt-2 w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg\"\n role=\"menu\"\n data-testid=\"profile-dropdown\"\n >\n {/* User info header */}\n {(displayName || email) && (\n <div className=\"flex items-center gap-3 border-b bg-muted/30 px-3 py-3\">\n <div className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-indigo/10 text-accent-indigo\">\n <User className=\"size-4\" aria-hidden=\"true\" />\n </div>\n <div className=\"flex min-w-0 flex-1 flex-col\">\n {displayName ? (\n <span className=\"truncate text-sm font-medium leading-5 text-foreground\">{displayName}</span>\n ) : null}\n {email ? (\n <span className={cn(\n 'truncate text-xs leading-4',\n displayName ? 'text-muted-foreground' : 'text-foreground font-medium',\n )}>\n {email}\n </span>\n ) : null}\n {!displayName && email ? (\n <span className=\"truncate text-overline uppercase tracking-wider text-muted-foreground/80\">\n {t('ui.userMenu.loggedInAs', 'Logged in')}\n </span>\n ) : null}\n </div>\n </div>\n )}\n\n <div className=\"flex flex-col p-1.5\">\n {mergedMenuItems.map((item) => (\n <React.Fragment key={item.id}>\n {item.separator ? <div className=\"my-1 h-px bg-border\" aria-hidden=\"true\" /> : null}\n {item.source === 'injected'\n ? (item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null)\n : renderBuiltInItem(item.id)}\n </React.Fragment>\n ))}\n <InjectionSpot\n spotId={BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID}\n context={injectionContext}\n />\n </div>\n </div>\n )}\n </div>\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { createPortal } from 'react-dom'\nimport Link from 'next/link'\nimport { User, LogOut, Bell, Moon, Sun, Globe, Key, Check, ChevronRight } from 'lucide-react'\nimport { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'\nimport { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'\nimport { useTheme } from '@open-mercato/ui/theme'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { IconButton } from '../primitives/icon-button'\nimport { Switch } from '../primitives/switch'\nimport { useInjectedMenuItems } from './injection/useInjectedMenuItems'\nimport { mergeMenuItems, type MergedMenuItem } from './injection/mergeMenuItems'\nimport { resolveInjectedIcon } from './injection/resolveInjectedIcon'\nimport { InjectionSpot } from './injection/InjectionSpot'\nimport { BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID } from './injection/spotIds'\n\nexport type ProfileDropdownProps = {\n email?: string\n displayName?: string\n changePasswordHref?: string\n notificationsHref?: string\n}\n\nconst useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect\n\nconst localeLabels: Record<Locale, string> = {\n en: 'English',\n de: 'Deutsch',\n es: 'Espa\u00F1ol',\n pl: 'Polski',\n}\n\nexport function ProfileDropdown({\n email,\n displayName,\n changePasswordHref = '/backend/profile/change-password',\n notificationsHref,\n}: ProfileDropdownProps) {\n const t = useT()\n const currentLocale = useLocale()\n const { resolvedTheme, setTheme } = useTheme()\n const [open, setOpen] = React.useState(false)\n const [languageOpen, setLanguageOpen] = React.useState(false)\n const [mounted, setMounted] = React.useState(false)\n const buttonRef = React.useRef<HTMLButtonElement>(null)\n const menuRef = React.useRef<HTMLDivElement>(null)\n const [menuPosition, setMenuPosition] = React.useState<{ top: number; right: number } | null>(null)\n const { items: injectedItems } = useInjectedMenuItems('menu:topbar:profile-dropdown')\n\n React.useEffect(() => {\n setMounted(true)\n }, [])\n\n // Position the portaled menu under the trigger. Rendering the menu in a body\n // portal keeps it out of the sticky header's backdrop-blur stacking context so\n // it always paints above page content such as the DataTable sticky Actions column.\n useIsomorphicLayoutEffect(() => {\n if (!open) {\n setMenuPosition(null)\n return\n }\n const updatePosition = () => {\n const trigger = buttonRef.current\n if (!trigger) return\n const rect = trigger.getBoundingClientRect()\n setMenuPosition({\n top: rect.bottom + 8,\n right: Math.max(0, window.innerWidth - rect.right),\n })\n }\n updatePosition()\n window.addEventListener('resize', updatePosition)\n window.addEventListener('scroll', updatePosition, true)\n return () => {\n window.removeEventListener('resize', updatePosition)\n window.removeEventListener('scroll', updatePosition, true)\n }\n }, [open])\n\n const isDark = resolvedTheme === 'dark'\n\n // Close on click outside\n React.useEffect(() => {\n if (!open) return\n function handleClick(event: MouseEvent) {\n if (\n menuRef.current &&\n !menuRef.current.contains(event.target as Node) &&\n buttonRef.current &&\n !buttonRef.current.contains(event.target as Node)\n ) {\n setOpen(false)\n setLanguageOpen(false)\n }\n }\n document.addEventListener('mousedown', handleClick)\n return () => document.removeEventListener('mousedown', handleClick)\n }, [open])\n\n // Close on Escape\n React.useEffect(() => {\n if (!open) return\n function handleKeyDown(event: KeyboardEvent) {\n if (event.key === 'Escape') {\n if (languageOpen) {\n setLanguageOpen(false)\n } else {\n setOpen(false)\n buttonRef.current?.focus()\n }\n }\n }\n document.addEventListener('keydown', handleKeyDown)\n return () => document.removeEventListener('keydown', handleKeyDown)\n }, [open, languageOpen])\n\n const handleThemeToggle = () => {\n setTheme(isDark ? 'light' : 'dark')\n }\n\n const handleLocaleChange = async (locale: Locale) => {\n try {\n await fetch('/api/auth/locale', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ locale }),\n })\n window.location.reload()\n } catch {}\n }\n\n // Unified row class \u2014 every menu item uses this for perfectly aligned layout.\n // h-9 keeps all rows the same height regardless of whether they have a trailing element.\n const menuItemClass = cn(\n 'group flex h-9 w-full items-center gap-3 rounded-md px-2.5 text-sm text-foreground',\n 'transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',\n 'cursor-pointer',\n )\n const menuIconClass = 'size-4 shrink-0 text-muted-foreground group-hover:text-foreground'\n\n const resolveMenuLabel = React.useCallback(\n (item: Pick<MergedMenuItem, 'id' | 'label' | 'labelKey'>): string => {\n if (item.labelKey && item.label) return t(item.labelKey, item.label)\n if (item.labelKey) return t(item.labelKey, item.id)\n if (item.label && item.label.includes('.')) return t(item.label, item.id)\n return item.label ?? item.id\n },\n [t],\n )\n\n const builtInMenuItems = React.useMemo(\n () => {\n const items: Array<{ id: string; separator?: boolean }> = [{ id: 'change-password' }]\n if (notificationsHref) items.push({ id: 'notifications' })\n items.push({ id: 'theme-toggle', separator: true }, { id: 'language' }, { id: 'sign-out', separator: true })\n return items\n },\n [notificationsHref],\n )\n\n const mergedMenuItems = React.useMemo(\n () => mergeMenuItems(builtInMenuItems, injectedItems),\n [builtInMenuItems, injectedItems],\n )\n const injectionContext = React.useMemo(\n () => ({\n email,\n displayName,\n locale: currentLocale,\n }),\n [currentLocale, displayName, email],\n )\n\n const renderInjectedItem = React.useCallback(\n (item: MergedMenuItem) => {\n const label = resolveMenuLabel(item)\n const icon = resolveInjectedIcon(item.icon)\n const inner = (\n <>\n <span className={menuIconClass}>{icon}</span>\n <span className=\"flex-1 truncate\">{label}</span>\n </>\n )\n if (item.href) {\n return (\n <Link\n key={item.id}\n href={item.href}\n className={menuItemClass}\n role=\"menuitem\"\n data-menu-item-id={item.id}\n onClick={() => setOpen(false)}\n >\n {inner}\n </Link>\n )\n }\n return (\n <button\n key={item.id}\n type=\"button\"\n className={menuItemClass}\n role=\"menuitem\"\n data-menu-item-id={item.id}\n onClick={() => {\n item.onClick?.()\n setOpen(false)\n }}\n >\n {inner}\n </button>\n )\n },\n [menuItemClass, menuIconClass, resolveMenuLabel],\n )\n\n const renderBuiltInItem = React.useCallback(\n (id: string) => {\n if (id === 'change-password') {\n return (\n <Link\n key={id}\n href={changePasswordHref}\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n >\n <Key className={menuIconClass} />\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.changePassword', 'Change Password')}</span>\n </Link>\n )\n }\n\n if (id === 'notifications' && notificationsHref) {\n return (\n <Link\n key={id}\n href={notificationsHref}\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n >\n <Bell className={menuIconClass} />\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.notifications', 'Notification Preferences')}</span>\n </Link>\n )\n }\n\n if (id === 'theme-toggle') {\n if (!mounted) return null\n return (\n <div\n key={id}\n className={cn(menuItemClass, 'justify-between')}\n role=\"menuitem\"\n onClick={handleThemeToggle}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n handleThemeToggle()\n }\n }}\n tabIndex={0}\n >\n <span className=\"flex flex-1 items-center gap-3\">\n {isDark ? <Moon className={menuIconClass} /> : <Sun className={menuIconClass} />}\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.theme', 'Theme')}</span>\n </span>\n <Switch\n checked={isDark}\n onCheckedChange={handleThemeToggle}\n aria-label={t('ui.profileMenu.theme', 'Theme')}\n onClick={(e) => e.stopPropagation()}\n />\n </div>\n )\n }\n\n if (id === 'language') {\n return (\n <div key={id} className=\"contents\">\n <button\n type=\"button\"\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setLanguageOpen(!languageOpen)}\n aria-expanded={languageOpen}\n >\n <Globe className={menuIconClass} />\n <span className=\"truncate\">{t('ui.profileMenu.language', 'Language')}</span>\n <span className=\"text-xs text-muted-foreground\">{localeLabels[currentLocale]}</span>\n <ChevronRight\n className={cn(\n 'ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform',\n languageOpen && 'rotate-90',\n )}\n aria-hidden=\"true\"\n />\n </button>\n {languageOpen && (\n <div className=\"ml-7 mr-1 flex flex-col gap-0.5 border-l pl-2 py-1\">\n {locales.map((locale) => (\n <button\n key={locale}\n type=\"button\"\n className={cn(\n 'flex h-8 w-full items-center justify-between gap-2 rounded-md px-2 text-sm transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',\n locale === currentLocale ? 'font-medium text-foreground' : 'text-muted-foreground',\n )}\n onClick={() => handleLocaleChange(locale)}\n >\n <span className=\"truncate\">{localeLabels[locale]}</span>\n {locale === currentLocale && <Check className=\"size-3.5 shrink-0 text-accent-indigo\" aria-hidden=\"true\" />}\n </button>\n ))}\n </div>\n )}\n </div>\n )\n }\n\n if (id === 'sign-out') {\n return (\n <form key={id} action=\"/api/auth/logout\" method=\"POST\">\n <button\n type=\"submit\"\n className={menuItemClass}\n role=\"menuitem\"\n >\n <LogOut className={menuIconClass} />\n <span className=\"flex-1 truncate text-left\">{t('ui.userMenu.logout', 'Sign Out')}</span>\n </button>\n </form>\n )\n }\n\n return null\n },\n [\n changePasswordHref,\n currentLocale,\n handleThemeToggle,\n isDark,\n languageOpen,\n menuItemClass,\n menuIconClass,\n mounted,\n notificationsHref,\n t,\n ],\n )\n\n return (\n <div className=\"relative\">\n <IconButton\n ref={buttonRef}\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setOpen(!open)}\n aria-expanded={open}\n aria-haspopup=\"menu\"\n data-testid=\"profile-dropdown-trigger\"\n title={email || t('ui.userMenu.userFallback', 'User')}\n >\n <User className=\"size-4\" />\n </IconButton>\n\n {open && menuPosition && typeof document !== 'undefined' && createPortal(\n <div\n ref={menuRef}\n className=\"fixed z-popover w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg\"\n style={{ top: menuPosition.top, right: menuPosition.right }}\n role=\"menu\"\n data-testid=\"profile-dropdown\"\n >\n {/* User info header */}\n {(displayName || email) && (\n <div className=\"flex items-center gap-3 border-b bg-muted/30 px-3 py-3\">\n <div className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-indigo/10 text-accent-indigo\">\n <User className=\"size-4\" aria-hidden=\"true\" />\n </div>\n <div className=\"flex min-w-0 flex-1 flex-col\">\n {displayName ? (\n <span className=\"truncate text-sm font-medium leading-5 text-foreground\">{displayName}</span>\n ) : null}\n {email ? (\n <span className={cn(\n 'truncate text-xs leading-4',\n displayName ? 'text-muted-foreground' : 'text-foreground font-medium',\n )}>\n {email}\n </span>\n ) : null}\n {!displayName && email ? (\n <span className=\"truncate text-overline uppercase tracking-wider text-muted-foreground/80\">\n {t('ui.userMenu.loggedInAs', 'Logged in')}\n </span>\n ) : null}\n </div>\n </div>\n )}\n\n <div className=\"flex flex-col p-1.5\">\n {mergedMenuItems.map((item) => (\n <React.Fragment key={item.id}>\n {item.separator ? <div className=\"my-1 h-px bg-border\" aria-hidden=\"true\" /> : null}\n {item.source === 'injected'\n ? (item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null)\n : renderBuiltInItem(item.id)}\n </React.Fragment>\n ))}\n <InjectionSpot\n spotId={BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID}\n context={injectionContext}\n />\n </div>\n </div>,\n document.body,\n )}\n </div>\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -10,11 +10,12 @@ function defaultFormatValue(value) {
10
10
  }
11
11
  return value.toLocaleString(void 0, { maximumFractionDigits: 2 });
12
12
  }
13
- function formatPercentageChange(value) {
14
- const formatted = Math.abs(value).toFixed(1);
15
- return `${formatted}%`;
13
+ function formatPercentageChange(value, unit = "%") {
14
+ const abs = Math.abs(value);
15
+ const formatted = Number.isInteger(abs) ? String(abs) : abs.toFixed(1);
16
+ return `${formatted}${unit}`;
16
17
  }
17
- function BadgeDelta({ direction, value }) {
18
+ function BadgeDelta({ direction, value, unit = "%", className = "", title = "Compared to previous period" }) {
18
19
  const baseClasses = "inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium";
19
20
  const directionClasses = {
20
21
  up: "bg-status-success-bg text-status-success-text",
@@ -29,15 +30,16 @@ function BadgeDelta({ direction, value }) {
29
30
  return /* @__PURE__ */ jsxs(
30
31
  "span",
31
32
  {
32
- className: `${baseClasses} ${directionClasses[direction]}`,
33
- title: "Compared to previous period",
33
+ className: `${baseClasses} ${directionClasses[direction]}${className ? ` ${className}` : ""}`,
34
+ title,
34
35
  children: [
35
36
  icons[direction],
36
- formatPercentageChange(value)
37
+ formatPercentageChange(value, unit)
37
38
  ]
38
39
  }
39
40
  );
40
41
  }
42
+ const DeltaBadge = BadgeDelta;
41
43
  function KpiCard({
42
44
  title,
43
45
  value,
@@ -49,12 +51,14 @@ function KpiCard({
49
51
  prefix = "",
50
52
  suffix = "",
51
53
  className = "",
52
- headerAction
54
+ headerAction,
55
+ footer,
56
+ titleClassName
53
57
  }) {
54
58
  const hasWrapper = !!title;
55
59
  const wrapperClass = hasWrapper ? `rounded-lg border bg-card p-4 ${className}` : className;
56
60
  const headerRow = title || headerAction ? /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 mb-2", children: [
57
- title && /* @__PURE__ */ jsx("p", { className: "text-sm font-medium text-muted-foreground", children: title }),
61
+ title && /* @__PURE__ */ jsx("p", { className: titleClassName ?? "text-sm font-medium text-muted-foreground", children: title }),
58
62
  headerAction
59
63
  ] }) : null;
60
64
  if (error) {
@@ -78,18 +82,22 @@ function KpiCard({
78
82
  return /* @__PURE__ */ jsxs("div", { className: wrapperClass, children: [
79
83
  headerRow,
80
84
  /* @__PURE__ */ jsxs("div", { className: "flex items-baseline gap-3", children: [
81
- /* @__PURE__ */ jsxs("p", { className: "text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground", children: [
82
- prefix,
83
- formatValue(value),
84
- suffix
85
+ /* @__PURE__ */ jsxs("p", { className: "flex items-baseline gap-1.5 text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground", children: [
86
+ /* @__PURE__ */ jsxs("span", { children: [
87
+ prefix,
88
+ formatValue(value)
89
+ ] }),
90
+ suffix ? /* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-muted-foreground", children: suffix }) : null
85
91
  ] }),
86
92
  trend && /* @__PURE__ */ jsx(BadgeDelta, { direction: trend.direction, value: trend.value })
87
93
  ] }),
88
- trend && comparisonLabel && /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: comparisonLabel })
94
+ trend && comparisonLabel && /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: comparisonLabel }),
95
+ footer != null && /* @__PURE__ */ jsx("div", { className: "mt-3", children: footer })
89
96
  ] });
90
97
  }
91
98
  var KpiCard_default = KpiCard;
92
99
  export {
100
+ DeltaBadge,
93
101
  KpiCard,
94
102
  KpiCard_default as default
95
103
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/charts/KpiCard.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Spinner } from '@open-mercato/ui/primitives/spinner'\n\nexport type KpiTrend = {\n value: number\n direction: 'up' | 'down' | 'unchanged'\n}\n\nexport type KpiCardProps = {\n title?: string\n value: number | null\n trend?: KpiTrend\n comparisonLabel?: string\n loading?: boolean\n error?: string | null\n formatValue?: (value: number) => string\n prefix?: string\n suffix?: string\n className?: string\n headerAction?: React.ReactNode\n}\n\nfunction defaultFormatValue(value: number): string {\n if (Math.abs(value) >= 1_000_000) {\n return `${(value / 1_000_000).toFixed(1)}M`\n }\n if (Math.abs(value) >= 1_000) {\n return `${(value / 1_000).toFixed(1)}K`\n }\n return value.toLocaleString(undefined, { maximumFractionDigits: 2 })\n}\n\nfunction formatPercentageChange(value: number): string {\n const formatted = Math.abs(value).toFixed(1)\n return `${formatted}%`\n}\n\ntype BadgeDeltaProps = {\n direction: 'up' | 'down' | 'unchanged'\n value: number\n}\n\nfunction BadgeDelta({ direction, value }: BadgeDeltaProps) {\n const baseClasses = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium'\n\n const directionClasses = {\n up: 'bg-status-success-bg text-status-success-text',\n down: 'bg-status-error-bg text-status-error-text',\n unchanged: 'bg-status-neutral-bg text-status-neutral-text',\n }\n\n const icons = {\n up: (\n <svg className=\"h-3 w-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 10l7-7m0 0l7 7m-7-7v18\" />\n </svg>\n ),\n down: (\n <svg className=\"h-3 w-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M19 14l-7 7m0 0l-7-7m7 7V3\" />\n </svg>\n ),\n unchanged: (\n <svg className=\"h-3 w-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 12h14\" />\n </svg>\n ),\n }\n\n return (\n <span\n className={`${baseClasses} ${directionClasses[direction]}`}\n title=\"Compared to previous period\"\n >\n {icons[direction]}\n {formatPercentageChange(value)}\n </span>\n )\n}\n\nexport function KpiCard({\n title,\n value,\n trend,\n comparisonLabel,\n loading,\n error,\n formatValue = defaultFormatValue,\n prefix = '',\n suffix = '',\n className = '',\n headerAction,\n}: KpiCardProps) {\n const hasWrapper = !!title\n const wrapperClass = hasWrapper ? `rounded-lg border bg-card p-4 ${className}` : className\n\n const headerRow = (title || headerAction) ? (\n <div className=\"flex items-center justify-between gap-2 mb-2\">\n {title && <p className=\"text-sm font-medium text-muted-foreground\">{title}</p>}\n {headerAction}\n </div>\n ) : null\n\n if (error) {\n return (\n <div className={wrapperClass}>\n {headerRow}\n <p className=\"text-sm text-destructive\">{error}</p>\n </div>\n )\n }\n\n if (loading) {\n return (\n <div className={wrapperClass}>\n {headerRow}\n <div className=\"flex items-center justify-center py-4\">\n <Spinner className=\"h-6 w-6 text-muted-foreground\" />\n </div>\n </div>\n )\n }\n\n if (value === null) {\n return (\n <div className={wrapperClass}>\n {headerRow}\n <p className=\"text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground\">--</p>\n </div>\n )\n }\n\n return (\n <div className={wrapperClass}>\n {headerRow}\n <div className=\"flex items-baseline gap-3\">\n <p className=\"text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground\">\n {prefix}\n {formatValue(value)}\n {suffix}\n </p>\n {trend && (\n <BadgeDelta direction={trend.direction} value={trend.value} />\n )}\n </div>\n {trend && comparisonLabel && (\n <p className=\"mt-1 text-xs text-muted-foreground\">{comparisonLabel}</p>\n )}\n </div>\n )\n}\n\nexport default KpiCard\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Spinner } from '@open-mercato/ui/primitives/spinner'\n\nexport type KpiTrend = {\n value: number\n direction: 'up' | 'down' | 'unchanged'\n}\n\nexport type KpiCardProps = {\n title?: string\n value: number | null\n trend?: KpiTrend\n comparisonLabel?: string\n loading?: boolean\n error?: string | null\n formatValue?: (value: number) => string\n prefix?: string\n suffix?: string\n className?: string\n headerAction?: React.ReactNode\n footer?: React.ReactNode\n titleClassName?: string\n}\n\nfunction defaultFormatValue(value: number): string {\n if (Math.abs(value) >= 1_000_000) {\n return `${(value / 1_000_000).toFixed(1)}M`\n }\n if (Math.abs(value) >= 1_000) {\n return `${(value / 1_000).toFixed(1)}K`\n }\n return value.toLocaleString(undefined, { maximumFractionDigits: 2 })\n}\n\nfunction formatPercentageChange(value: number, unit: string = '%'): string {\n const abs = Math.abs(value)\n const formatted = Number.isInteger(abs) ? String(abs) : abs.toFixed(1)\n return `${formatted}${unit}`\n}\n\ntype BadgeDeltaProps = {\n direction: 'up' | 'down' | 'unchanged'\n value: number\n unit?: string\n className?: string\n title?: string\n}\n\nfunction BadgeDelta({ direction, value, unit = '%', className = '', title = 'Compared to previous period' }: BadgeDeltaProps) {\n const baseClasses = 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium'\n\n const directionClasses = {\n up: 'bg-status-success-bg text-status-success-text',\n down: 'bg-status-error-bg text-status-error-text',\n unchanged: 'bg-status-neutral-bg text-status-neutral-text',\n }\n\n const icons = {\n up: (\n <svg className=\"h-3 w-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 10l7-7m0 0l7 7m-7-7v18\" />\n </svg>\n ),\n down: (\n <svg className=\"h-3 w-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M19 14l-7 7m0 0l-7-7m7 7V3\" />\n </svg>\n ),\n unchanged: (\n <svg className=\"h-3 w-3\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" strokeWidth={2}>\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 12h14\" />\n </svg>\n ),\n }\n\n return (\n <span\n className={`${baseClasses} ${directionClasses[direction]}${className ? ` ${className}` : ''}`}\n title={title}\n >\n {icons[direction]}\n {formatPercentageChange(value, unit)}\n </span>\n )\n}\n\nexport const DeltaBadge = BadgeDelta\n\nexport function KpiCard({\n title,\n value,\n trend,\n comparisonLabel,\n loading,\n error,\n formatValue = defaultFormatValue,\n prefix = '',\n suffix = '',\n className = '',\n headerAction,\n footer,\n titleClassName,\n}: KpiCardProps) {\n const hasWrapper = !!title\n const wrapperClass = hasWrapper ? `rounded-lg border bg-card p-4 ${className}` : className\n\n const headerRow = (title || headerAction) ? (\n <div className=\"flex items-center justify-between gap-2 mb-2\">\n {title && <p className={titleClassName ?? 'text-sm font-medium text-muted-foreground'}>{title}</p>}\n {headerAction}\n </div>\n ) : null\n\n if (error) {\n return (\n <div className={wrapperClass}>\n {headerRow}\n <p className=\"text-sm text-destructive\">{error}</p>\n </div>\n )\n }\n\n if (loading) {\n return (\n <div className={wrapperClass}>\n {headerRow}\n <div className=\"flex items-center justify-center py-4\">\n <Spinner className=\"h-6 w-6 text-muted-foreground\" />\n </div>\n </div>\n )\n }\n\n if (value === null) {\n return (\n <div className={wrapperClass}>\n {headerRow}\n <p className=\"text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground\">--</p>\n </div>\n )\n }\n\n return (\n <div className={wrapperClass}>\n {headerRow}\n <div className=\"flex items-baseline gap-3\">\n <p className=\"flex items-baseline gap-1.5 text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground\">\n <span>{prefix}{formatValue(value)}</span>\n {suffix ? <span className=\"text-sm font-medium text-muted-foreground\">{suffix}</span> : null}\n </p>\n {trend && (\n <BadgeDelta direction={trend.direction} value={trend.value} />\n )}\n </div>\n {trend && comparisonLabel && (\n <p className=\"mt-1 text-xs text-muted-foreground\">{comparisonLabel}</p>\n )}\n {footer != null && <div className=\"mt-3\">{footer}</div>}\n </div>\n )\n}\n\nexport default KpiCard\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,75 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ function buildPoints(values, width, height) {
4
+ const count = values.length;
5
+ const padding = 2;
6
+ const usableHeight = Math.max(height - padding * 2, 0);
7
+ if (count === 1) {
8
+ return [{ x: width / 2, y: height / 2 }];
9
+ }
10
+ let min = values[0];
11
+ let max = values[0];
12
+ for (const value of values) {
13
+ if (value < min) min = value;
14
+ if (value > max) max = value;
15
+ }
16
+ const range = max - min;
17
+ const stepX = count > 1 ? width / (count - 1) : 0;
18
+ return values.map((value, index) => {
19
+ const x = stepX * index;
20
+ const ratio = range === 0 ? 0.5 : (value - min) / range;
21
+ const y = padding + (1 - ratio) * usableHeight;
22
+ return { x, y };
23
+ });
24
+ }
25
+ function toPath(points) {
26
+ return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`).join(" ");
27
+ }
28
+ function Sparkline({
29
+ values,
30
+ ariaLabel,
31
+ className = "",
32
+ width = 96,
33
+ height = 28
34
+ }) {
35
+ if (!values || values.length === 0) {
36
+ return null;
37
+ }
38
+ const points = buildPoints(values, width, height);
39
+ const linePath = toPath(points);
40
+ const firstPoint = points[0];
41
+ const lastPoint = points[points.length - 1];
42
+ const areaPath = `${linePath} L ${lastPoint.x.toFixed(2)} ${height} L ${firstPoint.x.toFixed(2)} ${height} Z`;
43
+ return /* @__PURE__ */ jsxs(
44
+ "svg",
45
+ {
46
+ role: "img",
47
+ "aria-label": ariaLabel,
48
+ width,
49
+ height,
50
+ viewBox: `0 0 ${width} ${height}`,
51
+ preserveAspectRatio: "none",
52
+ className,
53
+ children: [
54
+ /* @__PURE__ */ jsx("path", { d: areaPath, fill: "currentColor", fillOpacity: 0.12, stroke: "none" }),
55
+ /* @__PURE__ */ jsx(
56
+ "path",
57
+ {
58
+ d: linePath,
59
+ fill: "none",
60
+ stroke: "currentColor",
61
+ strokeWidth: 1.5,
62
+ strokeLinecap: "round",
63
+ strokeLinejoin: "round"
64
+ }
65
+ )
66
+ ]
67
+ }
68
+ );
69
+ }
70
+ var Sparkline_default = Sparkline;
71
+ export {
72
+ Sparkline,
73
+ Sparkline_default as default
74
+ };
75
+ //# sourceMappingURL=Sparkline.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/backend/charts/Sparkline.tsx"],
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\n\nexport type SparklineProps = {\n values: number[]\n ariaLabel: string\n className?: string\n width?: number\n height?: number\n}\n\nfunction buildPoints(values: number[], width: number, height: number): Array<{ x: number; y: number }> {\n const count = values.length\n const padding = 2\n const usableHeight = Math.max(height - padding * 2, 0)\n\n if (count === 1) {\n return [{ x: width / 2, y: height / 2 }]\n }\n\n let min = values[0]\n let max = values[0]\n for (const value of values) {\n if (value < min) min = value\n if (value > max) max = value\n }\n const range = max - min\n\n const stepX = count > 1 ? width / (count - 1) : 0\n\n return values.map((value, index) => {\n const x = stepX * index\n const ratio = range === 0 ? 0.5 : (value - min) / range\n const y = padding + (1 - ratio) * usableHeight\n return { x, y }\n })\n}\n\nfunction toPath(points: Array<{ x: number; y: number }>): string {\n return points\n .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)\n .join(' ')\n}\n\nexport function Sparkline({\n values,\n ariaLabel,\n className = '',\n width = 96,\n height = 28,\n}: SparklineProps) {\n if (!values || values.length === 0) {\n return null\n }\n\n const points = buildPoints(values, width, height)\n const linePath = toPath(points)\n\n const firstPoint = points[0]\n const lastPoint = points[points.length - 1]\n const areaPath = `${linePath} L ${lastPoint.x.toFixed(2)} ${height} L ${firstPoint.x.toFixed(2)} ${height} Z`\n\n return (\n <svg\n role=\"img\"\n aria-label={ariaLabel}\n width={width}\n height={height}\n viewBox={`0 0 ${width} ${height}`}\n preserveAspectRatio=\"none\"\n className={className}\n >\n <path d={areaPath} fill=\"currentColor\" fillOpacity={0.12} stroke=\"none\" />\n <path\n d={linePath}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={1.5}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n />\n </svg>\n )\n}\n\nexport default Sparkline\n"],
5
+ "mappings": ";AAgEI,SASE,KATF;AApDJ,SAAS,YAAY,QAAkB,OAAe,QAAiD;AACrG,QAAM,QAAQ,OAAO;AACrB,QAAM,UAAU;AAChB,QAAM,eAAe,KAAK,IAAI,SAAS,UAAU,GAAG,CAAC;AAErD,MAAI,UAAU,GAAG;AACf,WAAO,CAAC,EAAE,GAAG,QAAQ,GAAG,GAAG,SAAS,EAAE,CAAC;AAAA,EACzC;AAEA,MAAI,MAAM,OAAO,CAAC;AAClB,MAAI,MAAM,OAAO,CAAC;AAClB,aAAW,SAAS,QAAQ;AAC1B,QAAI,QAAQ,IAAK,OAAM;AACvB,QAAI,QAAQ,IAAK,OAAM;AAAA,EACzB;AACA,QAAM,QAAQ,MAAM;AAEpB,QAAM,QAAQ,QAAQ,IAAI,SAAS,QAAQ,KAAK;AAEhD,SAAO,OAAO,IAAI,CAAC,OAAO,UAAU;AAClC,UAAM,IAAI,QAAQ;AAClB,UAAM,QAAQ,UAAU,IAAI,OAAO,QAAQ,OAAO;AAClD,UAAM,IAAI,WAAW,IAAI,SAAS;AAClC,WAAO,EAAE,GAAG,EAAE;AAAA,EAChB,CAAC;AACH;AAEA,SAAS,OAAO,QAAiD;AAC/D,SAAO,OACJ,IAAI,CAAC,OAAO,UAAU,GAAG,UAAU,IAAI,MAAM,GAAG,IAAI,MAAM,EAAE,QAAQ,CAAC,CAAC,IAAI,MAAM,EAAE,QAAQ,CAAC,CAAC,EAAE,EAC9F,KAAK,GAAG;AACb;AAEO,SAAS,UAAU;AAAA,EACxB;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,QAAQ;AAAA,EACR,SAAS;AACX,GAAmB;AACjB,MAAI,CAAC,UAAU,OAAO,WAAW,GAAG;AAClC,WAAO;AAAA,EACT;AAEA,QAAM,SAAS,YAAY,QAAQ,OAAO,MAAM;AAChD,QAAM,WAAW,OAAO,MAAM;AAE9B,QAAM,aAAa,OAAO,CAAC;AAC3B,QAAM,YAAY,OAAO,OAAO,SAAS,CAAC;AAC1C,QAAM,WAAW,GAAG,QAAQ,MAAM,UAAU,EAAE,QAAQ,CAAC,CAAC,IAAI,MAAM,MAAM,WAAW,EAAE,QAAQ,CAAC,CAAC,IAAI,MAAM;AAEzG,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAY;AAAA,MACZ;AAAA,MACA;AAAA,MACA,SAAS,OAAO,KAAK,IAAI,MAAM;AAAA,MAC/B,qBAAoB;AAAA,MACpB;AAAA,MAEA;AAAA,4BAAC,UAAK,GAAG,UAAU,MAAK,gBAAe,aAAa,MAAM,QAAO,QAAO;AAAA,QACxE;AAAA,UAAC;AAAA;AAAA,YACC,GAAG;AAAA,YACH,MAAK;AAAA,YACL,QAAO;AAAA,YACP,aAAa;AAAA,YACb,eAAc;AAAA,YACd,gBAAe;AAAA;AAAA,QACjB;AAAA;AAAA;AAAA,EACF;AAEJ;AAEA,IAAO,oBAAQ;",
6
+ "names": []
7
+ }
@@ -1,4 +1,5 @@
1
- import { KpiCard } from "./KpiCard.js";
1
+ import { KpiCard, DeltaBadge } from "./KpiCard.js";
2
+ import { Sparkline } from "./Sparkline.js";
2
3
  import { BarChart } from "./BarChart.js";
3
4
  import { LineChart } from "./LineChart.js";
4
5
  import { PieChart } from "./PieChart.js";
@@ -9,9 +10,11 @@ export {
9
10
  CHART_COLORS,
10
11
  ChartContainer,
11
12
  ChartTooltipContent,
13
+ DeltaBadge,
12
14
  KpiCard,
13
15
  LineChart,
14
16
  PieChart,
17
+ Sparkline,
15
18
  TopNTable,
16
19
  getChartColor
17
20
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/charts/index.ts"],
4
- "sourcesContent": ["export { KpiCard, type KpiCardProps, type KpiTrend } from './KpiCard'\nexport { BarChart, type BarChartProps, type BarChartDataItem } from './BarChart'\nexport { LineChart, type LineChartProps, type LineChartDataItem } from './LineChart'\nexport { PieChart, type PieChartProps, type PieChartDataItem } from './PieChart'\nexport { TopNTable, type TopNTableProps, type TopNTableColumn } from './TopNTable'\nexport { ChartContainer, ChartTooltipContent, CHART_COLORS, getChartColor, type ChartConfig } from './ChartUtils'\n"],
5
- "mappings": "AAAA,SAAS,eAAiD;AAC1D,SAAS,gBAA2D;AACpE,SAAS,iBAA8D;AACvE,SAAS,gBAA2D;AACpE,SAAS,iBAA4D;AACrE,SAAS,gBAAgB,qBAAqB,cAAc,qBAAuC;",
4
+ "sourcesContent": ["export { KpiCard, DeltaBadge, type KpiCardProps, type KpiTrend } from './KpiCard'\nexport { Sparkline, type SparklineProps } from './Sparkline'\nexport { BarChart, type BarChartProps, type BarChartDataItem } from './BarChart'\nexport { LineChart, type LineChartProps, type LineChartDataItem } from './LineChart'\nexport { PieChart, type PieChartProps, type PieChartDataItem } from './PieChart'\nexport { TopNTable, type TopNTableProps, type TopNTableColumn } from './TopNTable'\nexport { ChartContainer, ChartTooltipContent, CHART_COLORS, getChartColor, type ChartConfig } from './ChartUtils'\n"],
5
+ "mappings": "AAAA,SAAS,SAAS,kBAAoD;AACtE,SAAS,iBAAsC;AAC/C,SAAS,gBAA2D;AACpE,SAAS,iBAA8D;AACvE,SAAS,gBAA2D;AACpE,SAAS,iBAA4D;AACrE,SAAS,gBAAgB,qBAAqB,cAAc,qBAAuC;",
6
6
  "names": []
7
7
  }
@@ -9,7 +9,7 @@ import { Alert, AlertDescription, AlertTitle } from "@open-mercato/ui/primitives
9
9
  import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
10
10
  import { getDashboardWidgets, loadDashboardWidgetModule } from "./widgetRegistry.js";
11
11
  import { cn } from "@open-mercato/shared/lib/utils";
12
- import { GripVertical, Info, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from "lucide-react";
12
+ import { GripVertical, Plus, RefreshCw, Settings2, Trash2, X, Loader2 } from "lucide-react";
13
13
  import { useT } from "@open-mercato/shared/lib/i18n/context";
14
14
  import { useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
15
15
  import { InjectionSpot } from "../injection/InjectionSpot.js";
@@ -290,7 +290,6 @@ function DashboardScreen() {
290
290
  }
291
291
  if (!hasRegisteredWidgets && layout.length === 0) {
292
292
  return /* @__PURE__ */ jsxs(Alert, { variant: "info", children: [
293
- /* @__PURE__ */ jsx(Info, { className: "h-4 w-4", "aria-hidden": true }),
294
293
  /* @__PURE__ */ jsx(AlertTitle, { children: t("dashboard.empty.noWidgets.title", "No dashboard widgets yet") }),
295
294
  /* @__PURE__ */ jsx(AlertDescription, { children: t(
296
295
  "dashboard.empty.noWidgets.description",