@open-mercato/ui 0.8.1-develop.7296.1.2111d779db → 0.9.0
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/CrudForm.js +19 -6
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +62 -10
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/RowActions.js +54 -5
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/charts/KpiCard.js +2 -1
- package/dist/backend/charts/KpiCard.js.map +2 -2
- package/dist/backend/detail/AttachmentsSection.js +17 -5
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +38 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +26 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/eventBridge.js +33 -0
- package/dist/backend/injection/eventBridge.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +20 -3
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +1 -1
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +20 -2
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +2 -1
- package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
- package/dist/backend/messages/MessageComposer.js.map +2 -2
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +24 -0
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +27 -0
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +1 -0
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/notifications/NotificationItem.js +8 -1
- package/dist/backend/notifications/NotificationItem.js.map +2 -2
- package/dist/portal/PortalShell.js +2 -2
- package/dist/portal/PortalShell.js.map +2 -2
- package/dist/primitives/calendar.js +69 -14
- package/dist/primitives/calendar.js.map +2 -2
- package/package.json +4 -5
- package/src/backend/CrudForm.tsx +30 -8
- package/src/backend/DataTable.tsx +95 -20
- package/src/backend/RowActions.tsx +114 -6
- package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
- package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
- package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
- package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
- package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
- package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
- package/src/backend/__tests__/NotesSection.test.tsx +138 -0
- package/src/backend/__tests__/RowActions.test.tsx +202 -0
- package/src/backend/charts/KpiCard.tsx +1 -0
- package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
- package/src/backend/detail/AttachmentsSection.tsx +17 -5
- package/src/backend/detail/NotesSection.tsx +122 -66
- package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
- package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
- package/src/backend/injection/eventBridge.ts +35 -0
- package/src/backend/inputs/LookupSelect.tsx +32 -3
- package/src/backend/inputs/PhoneNumberField.tsx +1 -1
- package/src/backend/inputs/TagsInput.tsx +24 -3
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
- package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
- package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
- package/src/backend/messages/MessageComposer.tsx +1 -0
- package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
- package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
- package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
- package/src/backend/messages/index.ts +1 -0
- package/src/backend/messages/message-compose-form-groups.tsx +41 -0
- package/src/backend/messages/message-composer.types.ts +26 -0
- package/src/backend/messages/useMessageCompose.ts +46 -0
- package/src/backend/messages/useMessageComposeOperations.ts +8 -0
- package/src/backend/notifications/NotificationItem.tsx +7 -0
- package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
- package/src/portal/PortalShell.tsx +7 -4
- package/src/portal/__tests__/PortalShell.test.tsx +29 -1
- package/src/primitives/__tests__/calendar.test.tsx +174 -1
- package/src/primitives/calendar.tsx +168 -96
|
@@ -11,8 +11,13 @@ function RowActions({ items = [] }) {
|
|
|
11
11
|
const btnRef = React.useRef(null);
|
|
12
12
|
const menuRef = React.useRef(null);
|
|
13
13
|
const hoverTimeoutRef = React.useRef(null);
|
|
14
|
+
const focusOnOpenRef = React.useRef(false);
|
|
14
15
|
const [anchorRect, setAnchorRect] = React.useState(null);
|
|
15
16
|
const [direction, setDirection] = React.useState("down");
|
|
17
|
+
const getFocusableItems = React.useCallback(() => {
|
|
18
|
+
if (!menuRef.current) return [];
|
|
19
|
+
return Array.from(menuRef.current.querySelectorAll('[role="menuitem"]'));
|
|
20
|
+
}, []);
|
|
16
21
|
const updatePosition = React.useCallback(() => {
|
|
17
22
|
if (!btnRef.current) return;
|
|
18
23
|
const rect = btnRef.current.getBoundingClientRect();
|
|
@@ -31,10 +36,43 @@ function RowActions({ items = [] }) {
|
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
function onKey(e) {
|
|
39
|
+
const active = document.activeElement;
|
|
40
|
+
const focusWithin = Boolean(
|
|
41
|
+
menuRef.current && active && menuRef.current.contains(active) || active && active === btnRef.current
|
|
42
|
+
);
|
|
34
43
|
if (e.key === "Escape") {
|
|
35
44
|
setOpen(false);
|
|
36
|
-
btnRef.current?.focus();
|
|
45
|
+
if (focusWithin) btnRef.current?.focus();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!focusWithin) return;
|
|
49
|
+
const focusables = getFocusableItems();
|
|
50
|
+
if (!focusables.length) return;
|
|
51
|
+
const activeIndex = focusables.indexOf(document.activeElement);
|
|
52
|
+
let nextIndex = null;
|
|
53
|
+
switch (e.key) {
|
|
54
|
+
case "ArrowDown":
|
|
55
|
+
nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % focusables.length;
|
|
56
|
+
break;
|
|
57
|
+
case "ArrowUp":
|
|
58
|
+
nextIndex = activeIndex < 0 ? focusables.length - 1 : (activeIndex - 1 + focusables.length) % focusables.length;
|
|
59
|
+
break;
|
|
60
|
+
case "Home":
|
|
61
|
+
nextIndex = 0;
|
|
62
|
+
break;
|
|
63
|
+
case "End":
|
|
64
|
+
nextIndex = focusables.length - 1;
|
|
65
|
+
break;
|
|
66
|
+
case "Tab":
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
nextIndex = e.shiftKey ? activeIndex <= 0 ? focusables.length - 1 : activeIndex - 1 : activeIndex < 0 || activeIndex === focusables.length - 1 ? 0 : activeIndex + 1;
|
|
69
|
+
break;
|
|
70
|
+
default:
|
|
71
|
+
return;
|
|
37
72
|
}
|
|
73
|
+
if (nextIndex === null) return;
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
focusables[nextIndex]?.focus();
|
|
38
76
|
}
|
|
39
77
|
function onScrollOrResize() {
|
|
40
78
|
updatePosition();
|
|
@@ -49,7 +87,17 @@ function RowActions({ items = [] }) {
|
|
|
49
87
|
window.removeEventListener("scroll", onScrollOrResize, true);
|
|
50
88
|
window.removeEventListener("resize", onScrollOrResize);
|
|
51
89
|
};
|
|
52
|
-
}, [open, updatePosition]);
|
|
90
|
+
}, [open, updatePosition, getFocusableItems]);
|
|
91
|
+
React.useEffect(() => {
|
|
92
|
+
if (!open) {
|
|
93
|
+
focusOnOpenRef.current = false;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (!anchorRect || !focusOnOpenRef.current) return;
|
|
97
|
+
focusOnOpenRef.current = false;
|
|
98
|
+
const [first] = getFocusableItems();
|
|
99
|
+
first?.focus();
|
|
100
|
+
}, [open, anchorRect, getFocusableItems]);
|
|
53
101
|
React.useEffect(() => {
|
|
54
102
|
return () => {
|
|
55
103
|
if (hoverTimeoutRef.current) {
|
|
@@ -88,6 +136,7 @@ function RowActions({ items = [] }) {
|
|
|
88
136
|
"aria-expanded": open,
|
|
89
137
|
onClick: (e) => {
|
|
90
138
|
e.stopPropagation();
|
|
139
|
+
focusOnOpenRef.current = true;
|
|
91
140
|
setOpen(true);
|
|
92
141
|
requestAnimationFrame(updatePosition);
|
|
93
142
|
},
|
|
@@ -103,7 +152,7 @@ function RowActions({ items = [] }) {
|
|
|
103
152
|
{
|
|
104
153
|
ref: menuRef,
|
|
105
154
|
role: "menu",
|
|
106
|
-
className: "fixed w-44 max-w-
|
|
155
|
+
className: "fixed flex flex-col w-max min-w-44 max-w-xs rounded-md border bg-background p-1 shadow focus-visible:outline-none z-dropdown",
|
|
107
156
|
style: {
|
|
108
157
|
top: direction === "down" ? anchorRect.bottom + 8 : anchorRect.top - 8,
|
|
109
158
|
left: Math.min(anchorRect.right, window.innerWidth - 8),
|
|
@@ -115,7 +164,7 @@ function RowActions({ items = [] }) {
|
|
|
115
164
|
"a",
|
|
116
165
|
{
|
|
117
166
|
href: it.href,
|
|
118
|
-
className: `block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent ${it.destructive ? "text-destructive" : ""}`,
|
|
167
|
+
className: `block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent whitespace-normal ${it.destructive ? "text-destructive" : ""}`,
|
|
119
168
|
role: "menuitem",
|
|
120
169
|
onClick: (event) => {
|
|
121
170
|
event.stopPropagation();
|
|
@@ -130,7 +179,7 @@ function RowActions({ items = [] }) {
|
|
|
130
179
|
type: "button",
|
|
131
180
|
variant: "ghost",
|
|
132
181
|
size: "sm",
|
|
133
|
-
className: `w-full justify-start rounded-none font-normal ${it.destructive ? "text-destructive" : ""}`,
|
|
182
|
+
className: `w-full justify-start rounded-none font-normal whitespace-normal h-auto py-1.5 text-left ${it.destructive ? "text-destructive" : ""}`,
|
|
134
183
|
role: "menuitem",
|
|
135
184
|
onClick: (event) => {
|
|
136
185
|
event.stopPropagation();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/backend/RowActions.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { createPortal } from 'react-dom'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { IconButton } from '../primitives/icon-button'\nimport { Button } from '../primitives/button'\n\nexport type RowActionItem = {\n id?: string\n label: string\n onSelect?: () => void\n href?: string\n destructive?: boolean\n}\n\nexport function RowActions({ items = [] }: { items?: RowActionItem[] }) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const btnRef = React.useRef<HTMLButtonElement>(null)\n const menuRef = React.useRef<HTMLDivElement>(null)\n const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)\n const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null)\n const [direction, setDirection] = React.useState<'down' | 'up'>('down')\n\n const updatePosition = React.useCallback(() => {\n if (!btnRef.current) return\n const rect = btnRef.current.getBoundingClientRect()\n setAnchorRect(rect)\n // Decide whether to open up or down based on available viewport space\n const spaceBelow = window.innerHeight - rect.bottom\n const spaceAbove = rect.top\n setDirection(spaceBelow < 180 && spaceAbove > spaceBelow ? 'up' : 'down')\n }, [])\n\n React.useEffect(() => {\n if (!open) return\n updatePosition()\n function onDocClick(e: MouseEvent) {\n const t = e.target as Node\n if (menuRef.current && !menuRef.current.contains(t) && btnRef.current && !btnRef.current.contains(t)) {\n setOpen(false)\n }\n }\n function onKey(e: KeyboardEvent) {\n if (e.key === 'Escape') {\n setOpen(false)\n btnRef.current?.focus()\n }\n }\n function onScrollOrResize() {\n updatePosition()\n }\n document.addEventListener('mousedown', onDocClick)\n document.addEventListener('keydown', onKey)\n window.addEventListener('scroll', onScrollOrResize, true)\n window.addEventListener('resize', onScrollOrResize)\n return () => {\n document.removeEventListener('mousedown', onDocClick)\n document.removeEventListener('keydown', onKey)\n window.removeEventListener('scroll', onScrollOrResize, true)\n window.removeEventListener('resize', onScrollOrResize)\n }\n }, [open, updatePosition])\n\n // Cleanup timeout on unmount\n React.useEffect(() => {\n return () => {\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n }\n }, [])\n\n if (items.length === 0) return null\n\n const handlePointerEnter = (event: React.PointerEvent) => {\n if (event.pointerType === 'touch') return\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n setOpen(true)\n }\n\n const handlePointerLeave = (event: React.PointerEvent) => {\n if (event.pointerType === 'touch') return\n hoverTimeoutRef.current = setTimeout(() => {\n setOpen(false)\n }, 150)\n }\n\n return (\n <div\n className=\"relative inline-block text-left\"\n onPointerEnter={handlePointerEnter}\n onPointerLeave={handlePointerLeave}\n >\n <IconButton\n ref={btnRef}\n type=\"button\"\n variant=\"ghost\"\n aria-haspopup=\"menu\"\n aria-expanded={open}\n onClick={(e) => { e.stopPropagation(); setOpen(true); requestAnimationFrame(updatePosition) }}\n >\n <span aria-hidden=\"true\">\u22EF</span>\n <span className=\"sr-only\">{t('ui.rowActions.openActions', 'Open actions')}</span>\n </IconButton>\n {open && anchorRect && createPortal(\n <div\n ref={menuRef}\n role=\"menu\"\n className=\"fixed w-44 max-w-[calc(100vw-1rem)] rounded-md border bg-background p-1 shadow focus-visible:outline-none z-dropdown\"\n style={{\n top: direction === 'down' ? anchorRect.bottom + 8 : anchorRect.top - 8,\n left: Math.min(anchorRect.right, window.innerWidth - 8),\n transform: `translate(-100%, ${direction === 'down' ? '0' : '-100%'})`,\n }}\n onPointerEnter={handlePointerEnter}\n onPointerLeave={handlePointerLeave}\n >\n {items.map((it, idx) => (\n it.href ? (\n <a\n key={idx}\n href={it.href}\n className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent ${it.destructive ? 'text-destructive' : ''}`}\n role=\"menuitem\"\n onClick={(event) => {\n event.stopPropagation()\n setOpen(false)\n }}\n >\n {it.label}\n </a>\n ) : (\n <Button\n key={idx}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className={`w-full justify-start rounded-none font-normal ${it.destructive ? 'text-destructive' : ''}`}\n role=\"menuitem\"\n onClick={(event) => {\n event.stopPropagation()\n setOpen(false)\n it.onSelect?.()\n }}\n >\n {it.label}\n </Button>\n )\n ))}\n </div>,\n document.body\n )}\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { createPortal } from 'react-dom'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { IconButton } from '../primitives/icon-button'\nimport { Button } from '../primitives/button'\n\nexport type RowActionItem = {\n id?: string\n label: string\n onSelect?: () => void\n href?: string\n destructive?: boolean\n}\n\nexport function RowActions({ items = [] }: { items?: RowActionItem[] }) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const btnRef = React.useRef<HTMLButtonElement>(null)\n const menuRef = React.useRef<HTMLDivElement>(null)\n const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)\n /**\n * Whether this open should pull focus into the menu.\n *\n * Only a deliberate open does \u2014 a click, or Enter/Space on the trigger, which\n * the browser also reports as a click. The menu opens on hover too, and taking\n * focus on a hover open meant brushing the pointer over \u22EF while typing in a\n * list's search field pulled the caret out of the field (#6771).\n *\n * Consumed on use, so repositioning the panel mid-scroll cannot re-trigger it.\n */\n const focusOnOpenRef = React.useRef(false)\n const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null)\n const [direction, setDirection] = React.useState<'down' | 'up'>('down')\n\n /** The menu's items in DOM order, as focusable elements. */\n const getFocusableItems = React.useCallback((): HTMLElement[] => {\n if (!menuRef.current) return []\n return Array.from(menuRef.current.querySelectorAll<HTMLElement>('[role=\"menuitem\"]'))\n }, [])\n\n const updatePosition = React.useCallback(() => {\n if (!btnRef.current) return\n const rect = btnRef.current.getBoundingClientRect()\n setAnchorRect(rect)\n // Decide whether to open up or down based on available viewport space\n const spaceBelow = window.innerHeight - rect.bottom\n const spaceAbove = rect.top\n setDirection(spaceBelow < 180 && spaceAbove > spaceBelow ? 'up' : 'down')\n }, [])\n\n React.useEffect(() => {\n if (!open) return\n updatePosition()\n function onDocClick(e: MouseEvent) {\n const t = e.target as Node\n if (menuRef.current && !menuRef.current.contains(t) && btnRef.current && !btnRef.current.contains(t)) {\n setOpen(false)\n }\n }\n function onKey(e: KeyboardEvent) {\n // This listener is on `document`, and the menu can be open while the user\n // is working somewhere else entirely \u2014 it opens on hover. So every key\n // below is scoped to \"focus is actually in this menu\", otherwise a\n // hover-opened menu would swallow the arrows and Home/End of someone\n // typing in the list's search field (#6771). The trigger counts, so\n // ArrowDown from it still walks into the menu.\n const active = document.activeElement\n const focusWithin = Boolean(\n (menuRef.current && active && menuRef.current.contains(active)) || (active && active === btnRef.current),\n )\n if (e.key === 'Escape') {\n setOpen(false)\n // Only pull focus back if it was ours to begin with.\n if (focusWithin) btnRef.current?.focus()\n return\n }\n if (!focusWithin) return\n // Arrow keys move between items instead of doing nothing (#6718). Without\n // this the only way to reach an item was to Tab through it \u2014 and since\n // every row's trigger sits in the tab order, reaching the first item of a\n // six-row table took eleven Tab presses.\n const focusables = getFocusableItems()\n if (!focusables.length) return\n const activeIndex = focusables.indexOf(document.activeElement as HTMLElement)\n let nextIndex: number | null = null\n switch (e.key) {\n case 'ArrowDown':\n nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % focusables.length\n break\n case 'ArrowUp':\n nextIndex = activeIndex < 0\n ? focusables.length - 1\n : (activeIndex - 1 + focusables.length) % focusables.length\n break\n case 'Home':\n nextIndex = 0\n break\n case 'End':\n nextIndex = focusables.length - 1\n break\n case 'Tab':\n // A menu is modal for the keyboard: Tab must not escape it into the\n // next row's trigger, which is what made the menu feel unreachable.\n e.preventDefault()\n nextIndex = e.shiftKey\n ? (activeIndex <= 0 ? focusables.length - 1 : activeIndex - 1)\n : (activeIndex < 0 || activeIndex === focusables.length - 1 ? 0 : activeIndex + 1)\n break\n default:\n return\n }\n if (nextIndex === null) return\n e.preventDefault()\n focusables[nextIndex]?.focus()\n }\n function onScrollOrResize() {\n updatePosition()\n }\n document.addEventListener('mousedown', onDocClick)\n document.addEventListener('keydown', onKey)\n window.addEventListener('scroll', onScrollOrResize, true)\n window.addEventListener('resize', onScrollOrResize)\n return () => {\n document.removeEventListener('mousedown', onDocClick)\n document.removeEventListener('keydown', onKey)\n window.removeEventListener('scroll', onScrollOrResize, true)\n window.removeEventListener('resize', onScrollOrResize)\n }\n }, [open, updatePosition, getFocusableItems])\n\n // Move focus into the menu so the arrow keys have somewhere to start, and so a\n // screen reader announces the item rather than the still-focused trigger.\n //\n // Keyed on `anchorRect` rather than deferred with a timer: the panel only\n // renders once `updatePosition` has measured the trigger, which is a second\n // render, so on the first pass there is nothing to focus yet. This effect\n // re-runs when that measurement lands, by which point the portal has\n // committed. A `requestAnimationFrame` would also have waited for it, but\n // browsers throttle rAF in a background tab, so the focus could silently never\n // happen \u2014 a timer-free dependency cannot be throttled.\n React.useEffect(() => {\n if (!open) {\n focusOnOpenRef.current = false\n return\n }\n if (!anchorRect || !focusOnOpenRef.current) return\n focusOnOpenRef.current = false\n const [first] = getFocusableItems()\n first?.focus()\n }, [open, anchorRect, getFocusableItems])\n\n // Cleanup timeout on unmount\n React.useEffect(() => {\n return () => {\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n }\n }, [])\n\n if (items.length === 0) return null\n\n const handlePointerEnter = (event: React.PointerEvent) => {\n if (event.pointerType === 'touch') return\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n setOpen(true)\n }\n\n const handlePointerLeave = (event: React.PointerEvent) => {\n if (event.pointerType === 'touch') return\n hoverTimeoutRef.current = setTimeout(() => {\n setOpen(false)\n }, 150)\n }\n\n return (\n <div\n className=\"relative inline-block text-left\"\n onPointerEnter={handlePointerEnter}\n onPointerLeave={handlePointerLeave}\n >\n <IconButton\n ref={btnRef}\n type=\"button\"\n variant=\"ghost\"\n aria-haspopup=\"menu\"\n aria-expanded={open}\n // A click \u2014 and Enter/Space on the trigger, which the browser reports as\n // one \u2014 is a deliberate open, so it takes focus. Hover does not (#6771).\n onClick={(e) => { e.stopPropagation(); focusOnOpenRef.current = true; setOpen(true); requestAnimationFrame(updatePosition) }}\n >\n <span aria-hidden=\"true\">\u22EF</span>\n <span className=\"sr-only\">{t('ui.rowActions.openActions', 'Open actions')}</span>\n </IconButton>\n {open && anchorRect && createPortal(\n <div\n ref={menuRef}\n role=\"menu\"\n // Sized to its longest item instead of a fixed 11rem: the items are\n // Buttons, which are `whitespace-nowrap`, so a label wider than the box\n // overflowed its right border \u2014 \"Zarejestruj push ponownie\" in Polish\n // overhung by ~10px (#6719). `min-w-44` keeps the old width as a floor;\n // past `max-w-xs` the items wrap instead of growing further.\n //\n // `flex flex-col` is load-bearing for that, not cosmetic. Button is\n // `inline-flex`, so under a block panel the items are inline-level and\n // `max-content` is the width of them all laid end to end \u2014 523px here,\n // past the cap, so the panel was pinned at `max-w-xs` whatever the\n // labels said. They only looked stacked because `w-full` forced each\n // onto its own line. Blockifying them as flex items makes `max-content`\n // the widest item, which is what `w-max` was meant to measure.\n //\n // Same fix, same shape as ActionsDropdown (#3580).\n className=\"fixed flex flex-col w-max min-w-44 max-w-xs rounded-md border bg-background p-1 shadow focus-visible:outline-none z-dropdown\"\n style={{\n top: direction === 'down' ? anchorRect.bottom + 8 : anchorRect.top - 8,\n left: Math.min(anchorRect.right, window.innerWidth - 8),\n transform: `translate(-100%, ${direction === 'down' ? '0' : '-100%'})`,\n }}\n onPointerEnter={handlePointerEnter}\n onPointerLeave={handlePointerLeave}\n >\n {items.map((it, idx) => (\n it.href ? (\n <a\n key={idx}\n href={it.href}\n className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent whitespace-normal ${it.destructive ? 'text-destructive' : ''}`}\n role=\"menuitem\"\n onClick={(event) => {\n event.stopPropagation()\n setOpen(false)\n }}\n >\n {it.label}\n </a>\n ) : (\n <Button\n key={idx}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n // `whitespace-normal h-auto` so a label that reaches the panel's\n // max width wraps to a second line rather than overflowing: the\n // Button primitive is `whitespace-nowrap` by default (#6719).\n className={`w-full justify-start rounded-none font-normal whitespace-normal h-auto py-1.5 text-left ${it.destructive ? 'text-destructive' : ''}`}\n role=\"menuitem\"\n onClick={(event) => {\n event.stopPropagation()\n setOpen(false)\n it.onSelect?.()\n }}\n >\n {it.label}\n </Button>\n )\n ))}\n </div>,\n document.body\n )}\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["t"]
|
|
7
7
|
}
|
|
@@ -76,7 +76,8 @@ function KpiCard({
|
|
|
76
76
|
if (value === null) {
|
|
77
77
|
return /* @__PURE__ */ jsxs("div", { className: wrapperClass, children: [
|
|
78
78
|
headerRow,
|
|
79
|
-
/* @__PURE__ */ jsx("p", { className: "text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground", children: "--" })
|
|
79
|
+
/* @__PURE__ */ jsx("p", { className: "text-2xl sm:text-3xl font-semibold tracking-tight text-card-foreground", children: "--" }),
|
|
80
|
+
footer != null && /* @__PURE__ */ jsx("div", { className: "mt-3", children: footer })
|
|
80
81
|
] });
|
|
81
82
|
}
|
|
82
83
|
return /* @__PURE__ */ jsxs("div", { className: wrapperClass, children: [
|
|
@@ -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 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": ";AA8DQ,cAgBJ,YAhBI;AA3DR,SAAS,eAAe;AAuBxB,SAAS,mBAAmB,OAAuB;AACjD,MAAI,KAAK,IAAI,KAAK,KAAK,KAAW;AAChC,WAAO,IAAI,QAAQ,KAAW,QAAQ,CAAC,CAAC;AAAA,EAC1C;AACA,MAAI,KAAK,IAAI,KAAK,KAAK,KAAO;AAC5B,WAAO,IAAI,QAAQ,KAAO,QAAQ,CAAC,CAAC;AAAA,EACtC;AACA,SAAO,MAAM,eAAe,QAAW,EAAE,uBAAuB,EAAE,CAAC;AACrE;AAEA,SAAS,uBAAuB,OAAe,OAAe,KAAa;AACzE,QAAM,MAAM,KAAK,IAAI,KAAK;AAC1B,QAAM,YAAY,OAAO,UAAU,GAAG,IAAI,OAAO,GAAG,IAAI,IAAI,QAAQ,CAAC;AACrE,SAAO,GAAG,SAAS,GAAG,IAAI;AAC5B;AAUA,SAAS,WAAW,EAAE,WAAW,OAAO,OAAO,KAAK,YAAY,IAAI,QAAQ,8BAA8B,GAAoB;AAC5H,QAAM,cAAc;AAEpB,QAAM,mBAAmB;AAAA,IACvB,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,WAAW;AAAA,EACb;AAEA,QAAM,QAAQ;AAAA,IACZ,IACE,oBAAC,SAAI,WAAU,WAAU,MAAK,QAAO,SAAQ,aAAY,QAAO,gBAAe,aAAa,GAC1F,8BAAC,UAAK,eAAc,SAAQ,gBAAe,SAAQ,GAAE,6BAA4B,GACnF;AAAA,IAEF,MACE,oBAAC,SAAI,WAAU,WAAU,MAAK,QAAO,SAAQ,aAAY,QAAO,gBAAe,aAAa,GAC1F,8BAAC,UAAK,eAAc,SAAQ,gBAAe,SAAQ,GAAE,8BAA6B,GACpF;AAAA,IAEF,WACE,oBAAC,SAAI,WAAU,WAAU,MAAK,QAAO,SAAQ,aAAY,QAAO,gBAAe,aAAa,GAC1F,8BAAC,UAAK,eAAc,SAAQ,gBAAe,SAAQ,GAAE,YAAW,GAClE;AAAA,EAEJ;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,GAAG,WAAW,IAAI,iBAAiB,SAAS,CAAC,GAAG,YAAY,IAAI,SAAS,KAAK,EAAE;AAAA,MAC3F;AAAA,MAEC;AAAA,cAAM,SAAS;AAAA,QACf,uBAAuB,OAAO,IAAI;AAAA;AAAA;AAAA,EACrC;AAEJ;AAEO,MAAM,aAAa;AAEnB,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,SAAS;AAAA,EACT,SAAS;AAAA,EACT,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA;AACF,GAAiB;AACf,QAAM,aAAa,CAAC,CAAC;AACrB,QAAM,eAAe,aAAa,iCAAiC,SAAS,KAAK;AAEjF,QAAM,YAAa,SAAS,eAC1B,qBAAC,SAAI,WAAU,gDACZ;AAAA,aAAS,oBAAC,OAAE,WAAW,kBAAkB,6CAA8C,iBAAM;AAAA,IAC7F;AAAA,KACH,IACE;AAEJ,MAAI,OAAO;AACT,WACE,qBAAC,SAAI,WAAW,cACb;AAAA;AAAA,MACD,oBAAC,OAAE,WAAU,4BAA4B,iBAAM;AAAA,OACjD;AAAA,EAEJ;AAEA,MAAI,SAAS;AACX,WACE,qBAAC,SAAI,WAAW,cACb;AAAA;AAAA,MACD,oBAAC,SAAI,WAAU,yCACb,8BAAC,WAAQ,WAAU,iCAAgC,GACrD;AAAA,OACF;AAAA,EAEJ;AAEA,MAAI,UAAU,MAAM;AAClB,WACE,qBAAC,SAAI,WAAW,cACb;AAAA;AAAA,MACD,oBAAC,OAAE,WAAU,0EAAyE,gBAAE;AAAA,
|
|
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 {footer != null && <div className=\"mt-3\">{footer}</div>}\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": ";AA8DQ,cAgBJ,YAhBI;AA3DR,SAAS,eAAe;AAuBxB,SAAS,mBAAmB,OAAuB;AACjD,MAAI,KAAK,IAAI,KAAK,KAAK,KAAW;AAChC,WAAO,IAAI,QAAQ,KAAW,QAAQ,CAAC,CAAC;AAAA,EAC1C;AACA,MAAI,KAAK,IAAI,KAAK,KAAK,KAAO;AAC5B,WAAO,IAAI,QAAQ,KAAO,QAAQ,CAAC,CAAC;AAAA,EACtC;AACA,SAAO,MAAM,eAAe,QAAW,EAAE,uBAAuB,EAAE,CAAC;AACrE;AAEA,SAAS,uBAAuB,OAAe,OAAe,KAAa;AACzE,QAAM,MAAM,KAAK,IAAI,KAAK;AAC1B,QAAM,YAAY,OAAO,UAAU,GAAG,IAAI,OAAO,GAAG,IAAI,IAAI,QAAQ,CAAC;AACrE,SAAO,GAAG,SAAS,GAAG,IAAI;AAC5B;AAUA,SAAS,WAAW,EAAE,WAAW,OAAO,OAAO,KAAK,YAAY,IAAI,QAAQ,8BAA8B,GAAoB;AAC5H,QAAM,cAAc;AAEpB,QAAM,mBAAmB;AAAA,IACvB,IAAI;AAAA,IACJ,MAAM;AAAA,IACN,WAAW;AAAA,EACb;AAEA,QAAM,QAAQ;AAAA,IACZ,IACE,oBAAC,SAAI,WAAU,WAAU,MAAK,QAAO,SAAQ,aAAY,QAAO,gBAAe,aAAa,GAC1F,8BAAC,UAAK,eAAc,SAAQ,gBAAe,SAAQ,GAAE,6BAA4B,GACnF;AAAA,IAEF,MACE,oBAAC,SAAI,WAAU,WAAU,MAAK,QAAO,SAAQ,aAAY,QAAO,gBAAe,aAAa,GAC1F,8BAAC,UAAK,eAAc,SAAQ,gBAAe,SAAQ,GAAE,8BAA6B,GACpF;AAAA,IAEF,WACE,oBAAC,SAAI,WAAU,WAAU,MAAK,QAAO,SAAQ,aAAY,QAAO,gBAAe,aAAa,GAC1F,8BAAC,UAAK,eAAc,SAAQ,gBAAe,SAAQ,GAAE,YAAW,GAClE;AAAA,EAEJ;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,GAAG,WAAW,IAAI,iBAAiB,SAAS,CAAC,GAAG,YAAY,IAAI,SAAS,KAAK,EAAE;AAAA,MAC3F;AAAA,MAEC;AAAA,cAAM,SAAS;AAAA,QACf,uBAAuB,OAAO,IAAI;AAAA;AAAA;AAAA,EACrC;AAEJ;AAEO,MAAM,aAAa;AAEnB,SAAS,QAAQ;AAAA,EACtB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,SAAS;AAAA,EACT,SAAS;AAAA,EACT,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA;AACF,GAAiB;AACf,QAAM,aAAa,CAAC,CAAC;AACrB,QAAM,eAAe,aAAa,iCAAiC,SAAS,KAAK;AAEjF,QAAM,YAAa,SAAS,eAC1B,qBAAC,SAAI,WAAU,gDACZ;AAAA,aAAS,oBAAC,OAAE,WAAW,kBAAkB,6CAA8C,iBAAM;AAAA,IAC7F;AAAA,KACH,IACE;AAEJ,MAAI,OAAO;AACT,WACE,qBAAC,SAAI,WAAW,cACb;AAAA;AAAA,MACD,oBAAC,OAAE,WAAU,4BAA4B,iBAAM;AAAA,OACjD;AAAA,EAEJ;AAEA,MAAI,SAAS;AACX,WACE,qBAAC,SAAI,WAAW,cACb;AAAA;AAAA,MACD,oBAAC,SAAI,WAAU,yCACb,8BAAC,WAAQ,WAAU,iCAAgC,GACrD;AAAA,OACF;AAAA,EAEJ;AAEA,MAAI,UAAU,MAAM;AAClB,WACE,qBAAC,SAAI,WAAW,cACb;AAAA;AAAA,MACD,oBAAC,OAAE,WAAU,0EAAyE,gBAAE;AAAA,MACvF,UAAU,QAAQ,oBAAC,SAAI,WAAU,QAAQ,kBAAO;AAAA,OACnD;AAAA,EAEJ;AAEA,SACE,qBAAC,SAAI,WAAW,cACb;AAAA;AAAA,IACD,qBAAC,SAAI,WAAU,6BACb;AAAA,2BAAC,OAAE,WAAU,sGACX;AAAA,6BAAC,UAAM;AAAA;AAAA,UAAQ,YAAY,KAAK;AAAA,WAAE;AAAA,QACjC,SAAS,oBAAC,UAAK,WAAU,6CAA6C,kBAAO,IAAU;AAAA,SAC1F;AAAA,MACC,SACC,oBAAC,cAAW,WAAW,MAAM,WAAW,OAAO,MAAM,OAAO;AAAA,OAEhE;AAAA,IACC,SAAS,mBACR,oBAAC,OAAE,WAAU,sCAAsC,2BAAgB;AAAA,IAEpE,UAAU,QAAQ,oBAAC,SAAI,WAAU,QAAQ,kBAAO;AAAA,KACnD;AAEJ;AAEA,IAAO,kBAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -36,6 +36,7 @@ function AttachmentsSectionImpl({
|
|
|
36
36
|
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
|
37
37
|
const [deleteTarget, setDeleteTarget] = React.useState(null);
|
|
38
38
|
const fileInputRef = React.useRef(null);
|
|
39
|
+
const uploadingRef = React.useRef(false);
|
|
39
40
|
const load = React.useCallback(async (targetPage = 1, replace = true) => {
|
|
40
41
|
if (!recordId) return;
|
|
41
42
|
setLoading(true);
|
|
@@ -87,9 +88,12 @@ function AttachmentsSectionImpl({
|
|
|
87
88
|
}, [load, recordId]);
|
|
88
89
|
const acceptFiles = React.useCallback(
|
|
89
90
|
async (files) => {
|
|
90
|
-
if (!files || !files.length || !recordId) return;
|
|
91
|
+
if (!files || !files.length || !recordId || uploadingRef.current) return;
|
|
92
|
+
uploadingRef.current = true;
|
|
91
93
|
setError(null);
|
|
92
94
|
setIsUploading(true);
|
|
95
|
+
let uploadedCount = 0;
|
|
96
|
+
let uploadError = null;
|
|
93
97
|
try {
|
|
94
98
|
for (const file of Array.from(files)) {
|
|
95
99
|
const fd = new FormData();
|
|
@@ -105,12 +109,19 @@ function AttachmentsSectionImpl({
|
|
|
105
109
|
const message = call.result?.error || t("attachments.library.upload.failed", "Upload failed.");
|
|
106
110
|
throw new Error(message);
|
|
107
111
|
}
|
|
112
|
+
uploadedCount += 1;
|
|
108
113
|
}
|
|
109
|
-
await load(1, true);
|
|
110
|
-
onChanged?.();
|
|
111
114
|
} catch (err) {
|
|
112
|
-
|
|
115
|
+
uploadError = err?.message || t("attachments.library.upload.failed", "Upload failed.");
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
if (uploadedCount > 0) {
|
|
119
|
+
await load(1, true);
|
|
120
|
+
onChanged?.();
|
|
121
|
+
}
|
|
113
122
|
} finally {
|
|
123
|
+
if (uploadError) setError(uploadError);
|
|
124
|
+
uploadingRef.current = false;
|
|
114
125
|
setIsUploading(false);
|
|
115
126
|
if (fileInputRef.current) {
|
|
116
127
|
fileInputRef.current.value = "";
|
|
@@ -218,13 +229,14 @@ function AttachmentsSectionImpl({
|
|
|
218
229
|
type: "file",
|
|
219
230
|
multiple: true,
|
|
220
231
|
className: "hidden",
|
|
232
|
+
disabled: isUploading,
|
|
221
233
|
onChange: (event) => void acceptFiles(event.target.files)
|
|
222
234
|
}
|
|
223
235
|
)
|
|
224
236
|
]
|
|
225
237
|
}
|
|
226
238
|
),
|
|
227
|
-
error ? /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-status-error-text", children: error }) : null,
|
|
239
|
+
error ? /* @__PURE__ */ jsx("p", { role: "alert", className: "text-xs font-medium text-status-error-text", children: error }) : null,
|
|
228
240
|
loading ? /* @__PURE__ */ jsx("div", { className: "text-sm text-muted-foreground", children: t("attachments.library.loading", "Loading attachments\u2026") }) : items.length ? /* @__PURE__ */ jsx("div", { className: cn(
|
|
229
241
|
"grid gap-3",
|
|
230
242
|
compact ? "grid-cols-2 sm:grid-cols-4 lg:grid-cols-5" : "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/detail/AttachmentsSection.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Upload, Trash2, File, FileText, FileSpreadsheet, FileArchive, FileAudio, FileVideo, FileCode } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { hasMoreFromPage } from '@open-mercato/shared/lib/pagination/load-more'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { AttachmentVisualPreview, formatAttachmentFileSize } from './AttachmentVisualPreview'\nimport { AttachmentDeleteDialog } from './AttachmentDeleteDialog'\nimport { AttachmentMetadataDialog, type AttachmentItem, type AttachmentMetadataSavePayload } from './AttachmentMetadataDialog'\nimport { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'\nimport { useRegisteredComponent } from '../injection/useRegisteredComponent'\n\ntype AttachmentsResponse = {\n items?: AttachmentItem[]\n total?: number\n page?: number\n pageSize?: number\n totalPages?: number\n error?: string\n}\n\nconst PAGE_SIZE = 24\n\ntype Props = {\n entityId: string\n recordId: string | null\n title?: string\n description?: string\n className?: string\n showHeader?: boolean\n compact?: boolean\n onChanged?: () => void\n}\n\nfunction AttachmentsSectionImpl({\n entityId,\n recordId,\n title,\n description,\n className,\n showHeader = true,\n compact = false,\n onChanged,\n}: Props) {\n const t = useT()\n const [items, setItems] = React.useState<AttachmentItem[]>([])\n const [page, setPage] = React.useState(1)\n // Short-page termination instead of a `total`/`totalPages` bound \u2014 see\n // `hasMoreFromPage`. This endpoint clamps, so `load` also checks it was\n // served the page it asked for.\n const [hasMore, setHasMore] = React.useState(false)\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [isUploading, setIsUploading] = React.useState(false)\n const [isDragOver, setIsDragOver] = React.useState(false)\n const [metadataOpen, setMetadataOpen] = React.useState(false)\n const [selectedItem, setSelectedItem] = React.useState<AttachmentItem | null>(null)\n const [deleteOpen, setDeleteOpen] = React.useState(false)\n const [deleteTarget, setDeleteTarget] = React.useState<AttachmentItem | null>(null)\n const fileInputRef = React.useRef<HTMLInputElement | null>(null)\n\n const load = React.useCallback(async (targetPage = 1, replace = true) => {\n if (!recordId) return\n setLoading(true)\n setError(null)\n try {\n const params = new URLSearchParams({\n entityId,\n recordId,\n page: String(targetPage),\n pageSize: String(PAGE_SIZE),\n })\n const call = await apiCall<AttachmentsResponse>(\n `/api/attachments?${params.toString()}`,\n undefined,\n { fallback: { items: [] } },\n )\n if (!call.ok) {\n const message = call.result?.error || t('attachments.library.errors.load', 'Failed to load attachments.')\n throw new Error(message)\n }\n const payload = call.result ?? { items: [] }\n const nextItems = Array.isArray(payload.items) ? payload.items : []\n // `/api/attachments` clamps the requested page to the last one \u2014 for the\n // offset and for the page it echoes back \u2014 so a request past the end does\n // not come back short, it comes back full, re-serving the last page. Take\n // an echoed page below the one asked for as the end of the list: appending\n // it would duplicate every row, and its clamped page number would never\n // advance, leaving the affordance with no terminating state.\n const returnedPage = typeof payload.page === 'number' ? payload.page : targetPage\n const servedRequestedPage = returnedPage >= targetPage\n setItems((current) => {\n if (replace) return nextItems\n if (!servedRequestedPage) return current\n const merged = new Map(current.map((item) => [item.id, item]))\n nextItems.forEach((item) => merged.set(item.id, item))\n return Array.from(merged.values())\n })\n setPage(returnedPage)\n setHasMore(servedRequestedPage && hasMoreFromPage(nextItems.length, PAGE_SIZE))\n } catch (err: any) {\n setError(err?.message || t('attachments.library.errors.load', 'Failed to load attachments.'))\n } finally {\n setLoading(false)\n }\n }, [entityId, recordId, t])\n\n React.useEffect(() => {\n if (recordId) {\n void load()\n } else {\n setItems([])\n setPage(1)\n setHasMore(false)\n setError(null)\n }\n }, [load, recordId])\n\n const acceptFiles = React.useCallback(\n async (files: FileList | null) => {\n if (!files || !files.length || !recordId) return\n setError(null)\n setIsUploading(true)\n try {\n for (const file of Array.from(files)) {\n const fd = new FormData()\n fd.set('entityId', entityId)\n fd.set('recordId', recordId)\n fd.set('file', file)\n const call = await apiCall<{ ok?: boolean; item?: AttachmentItem; error?: string }>(\n '/api/attachments',\n { method: 'POST', body: fd },\n { fallback: null },\n )\n if (!call.ok) {\n const message = call.result?.error || t('attachments.library.upload.failed', 'Upload failed.')\n throw new Error(message)\n }\n }\n await load(1, true)\n onChanged?.()\n } catch (err: any) {\n setError(err?.message || t('attachments.library.upload.failed', 'Upload failed.'))\n } finally {\n setIsUploading(false)\n if (fileInputRef.current) {\n fileInputRef.current.value = ''\n }\n }\n },\n [entityId, load, onChanged, recordId, t],\n )\n\n const handleDrop = React.useCallback(\n (event: React.DragEvent<HTMLDivElement>) => {\n event.preventDefault()\n event.stopPropagation()\n setIsDragOver(false)\n void acceptFiles(event.dataTransfer?.files ?? null)\n },\n [acceptFiles],\n )\n\n const handleDragOver = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {\n event.preventDefault()\n event.stopPropagation()\n setIsDragOver(true)\n }, [])\n\n const handleDragLeave = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {\n event.preventDefault()\n event.stopPropagation()\n setIsDragOver(false)\n }, [])\n\n const openMetadataDialog = React.useCallback((item: AttachmentItem) => {\n setSelectedItem(item)\n setMetadataOpen(true)\n }, [])\n\n const openDeleteDialog = React.useCallback((item: AttachmentItem) => {\n setDeleteTarget(item)\n setDeleteOpen(true)\n }, [])\n\n const handleDelete = React.useCallback(async () => {\n if (!deleteTarget) return\n try {\n const call = await apiCall<{ error?: string }>(\n `/api/attachments?id=${encodeURIComponent(deleteTarget.id)}`,\n { method: 'DELETE' },\n )\n if (!call.ok) {\n const message = call.result?.error || t('attachments.library.errors.delete', 'Failed to delete attachment.')\n throw new Error(message)\n }\n setDeleteOpen(false)\n setDeleteTarget(null)\n await load(1, true)\n onChanged?.()\n } catch (err: any) {\n setError(err?.message || t('attachments.library.errors.delete', 'Failed to delete attachment.'))\n }\n }, [deleteTarget, load, onChanged, t])\n\n const handleMetadataSave = React.useCallback(\n async (id: string, payload: AttachmentMetadataSavePayload) => {\n const body: Record<string, unknown> = {\n tags: payload.tags,\n assignments: payload.assignments,\n }\n if (payload.customFields && Object.keys(payload.customFields).length) {\n body.customFields = payload.customFields\n }\n const call = await apiCall<{ error?: string }>(`/api/attachments/library/${encodeURIComponent(id)}`, {\n method: 'PATCH',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!call.ok) {\n const message = call.result?.error || t('attachments.library.metadata.error', 'Failed to update metadata.')\n throw new Error(message)\n }\n setMetadataOpen(false)\n await load(1, true)\n onChanged?.()\n },\n [load, onChanged, t],\n )\n\n const sectionTitle = title ?? t('attachments.library.title', 'Attachments')\n const sectionDescription =\n description ?? t('attachments.library.description', 'Browse, tag, and manage every file stored in this workspace.')\n\n return (\n <div className={cn('space-y-4', className)}>\n {showHeader ? (\n <div className=\"space-y-1\">\n <div className=\"text-base font-medium\">{sectionTitle}</div>\n <div className=\"text-sm text-muted-foreground\">{sectionDescription}</div>\n </div>\n ) : null}\n\n {!recordId ? (\n <div className=\"rounded-md border border-dashed border-border/70 px-4 py-6 text-sm text-muted-foreground\">\n {t('attachments.library.upload.saveFirst', 'Save the record before uploading files.')}\n </div>\n ) : (\n <div\n className={cn(\n 'flex flex-col items-center justify-center rounded-lg border border-dashed p-6 text-center transition-colors',\n isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/30',\n )}\n onDrop={handleDrop}\n onDragOver={handleDragOver}\n onDragLeave={handleDragLeave}\n role=\"presentation\"\n >\n <Upload className=\"mx-auto h-6 w-6 text-muted-foreground\" />\n <p className=\"mt-2 text-sm text-muted-foreground\">\n {t('attachments.library.upload.dropHint', 'Drag and drop files here or click to upload.')}\n </p>\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"mt-4\" onClick={() => fileInputRef.current?.click()} disabled={isUploading}>\n {isUploading ? t('attachments.library.upload.submitting', 'Uploading\u2026') : t('attachments.library.upload.choose', 'Choose files')}\n </Button>\n <input\n ref={fileInputRef}\n type=\"file\"\n multiple\n className=\"hidden\"\n onChange={(event) => void acceptFiles(event.target.files)}\n />\n </div>\n )}\n\n {error ? <p className=\"text-xs font-medium text-status-error-text\">{error}</p> : null}\n\n {loading ? (\n <div className=\"text-sm text-muted-foreground\">{t('attachments.library.loading', 'Loading attachments\u2026')}</div>\n ) : items.length ? (\n <div className={cn(\n 'grid gap-3',\n compact ? 'grid-cols-2 sm:grid-cols-4 lg:grid-cols-5' : 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',\n )}>\n {items.map((item) => {\n return (\n <div\n key={item.id}\n role=\"group\"\n className=\"group relative flex flex-col overflow-hidden rounded-lg border bg-card text-left transition-shadow hover:shadow-sm focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2\"\n >\n <Button\n type=\"button\"\n variant=\"ghost\"\n aria-label={item.fileName}\n onClick={() => openMetadataDialog(item)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n openMetadataDialog(item)\n }\n }}\n className=\"flex h-auto w-full flex-col items-stretch rounded-lg p-0 text-left hover:bg-transparent focus-visible:outline-none focus-visible:ring-0\"\n >\n <AttachmentVisualPreview\n fileName={item.fileName}\n mimeType={item.mimeType}\n thumbnailUrl={item.thumbnailUrl}\n className={compact ? 'aspect-[2/1] w-full' : 'aspect-[4/3] w-full'}\n />\n <div className={cn('space-y-1 w-full', compact ? 'p-2' : 'p-3')}>\n <div className={cn('truncate font-medium', compact ? 'text-xs' : 'text-sm')} title={item.fileName}>\n {item.fileName}\n </div>\n <div className=\"text-xs text-muted-foreground\">\n {formatAttachmentFileSize(item.fileSize)}\n </div>\n </div>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"absolute right-2 top-2 z-10 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100\"\n onClick={(event) => {\n event.stopPropagation()\n openDeleteDialog(item)\n }}\n aria-label={t('attachments.library.delete', 'Delete attachment')}\n >\n <Trash2 className=\"h-4 w-4 text-destructive\" />\n </Button>\n </div>\n )\n })}\n </div>\n ) : (\n <div className=\"text-sm text-muted-foreground\">\n {t('attachments.library.table.empty', 'No attachments found.')}\n </div>\n )}\n\n {items.length > 0 && hasMore ? (\n <div className=\"flex justify-center\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => { void load(page + 1, false) }}\n disabled={loading}\n >\n {t('attachments.library.loadMore', 'Load more')}\n </Button>\n </div>\n ) : null}\n\n <AttachmentMetadataDialog\n open={metadataOpen}\n onOpenChange={setMetadataOpen}\n item={selectedItem}\n availableTags={[]}\n onSave={handleMetadataSave}\n />\n <AttachmentDeleteDialog\n open={deleteOpen}\n onOpenChange={setDeleteOpen}\n fileName={deleteTarget?.fileName}\n onConfirm={handleDelete}\n isDeleting={false}\n />\n </div>\n )\n}\n\nexport function AttachmentsSection(props: Props) {\n const handle = ComponentReplacementHandles.section('ui.detail', 'AttachmentsSection')\n const Resolved = useRegisteredComponent<Props>(\n handle,\n AttachmentsSectionImpl as React.ComponentType<Props>,\n )\n\n return (\n <div data-component-handle={handle}>\n <Resolved {...props} />\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Upload, Trash2, File, FileText, FileSpreadsheet, FileArchive, FileAudio, FileVideo, FileCode } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { hasMoreFromPage } from '@open-mercato/shared/lib/pagination/load-more'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { AttachmentVisualPreview, formatAttachmentFileSize } from './AttachmentVisualPreview'\nimport { AttachmentDeleteDialog } from './AttachmentDeleteDialog'\nimport { AttachmentMetadataDialog, type AttachmentItem, type AttachmentMetadataSavePayload } from './AttachmentMetadataDialog'\nimport { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry'\nimport { useRegisteredComponent } from '../injection/useRegisteredComponent'\n\ntype AttachmentsResponse = {\n items?: AttachmentItem[]\n total?: number\n page?: number\n pageSize?: number\n totalPages?: number\n error?: string\n}\n\nconst PAGE_SIZE = 24\n\ntype Props = {\n entityId: string\n recordId: string | null\n title?: string\n description?: string\n className?: string\n showHeader?: boolean\n compact?: boolean\n onChanged?: () => void\n}\n\nfunction AttachmentsSectionImpl({\n entityId,\n recordId,\n title,\n description,\n className,\n showHeader = true,\n compact = false,\n onChanged,\n}: Props) {\n const t = useT()\n const [items, setItems] = React.useState<AttachmentItem[]>([])\n const [page, setPage] = React.useState(1)\n // Short-page termination instead of a `total`/`totalPages` bound \u2014 see\n // `hasMoreFromPage`. This endpoint clamps, so `load` also checks it was\n // served the page it asked for.\n const [hasMore, setHasMore] = React.useState(false)\n const [loading, setLoading] = React.useState(false)\n const [error, setError] = React.useState<string | null>(null)\n const [isUploading, setIsUploading] = React.useState(false)\n const [isDragOver, setIsDragOver] = React.useState(false)\n const [metadataOpen, setMetadataOpen] = React.useState(false)\n const [selectedItem, setSelectedItem] = React.useState<AttachmentItem | null>(null)\n const [deleteOpen, setDeleteOpen] = React.useState(false)\n const [deleteTarget, setDeleteTarget] = React.useState<AttachmentItem | null>(null)\n const fileInputRef = React.useRef<HTMLInputElement | null>(null)\n const uploadingRef = React.useRef(false)\n\n const load = React.useCallback(async (targetPage = 1, replace = true) => {\n if (!recordId) return\n setLoading(true)\n setError(null)\n try {\n const params = new URLSearchParams({\n entityId,\n recordId,\n page: String(targetPage),\n pageSize: String(PAGE_SIZE),\n })\n const call = await apiCall<AttachmentsResponse>(\n `/api/attachments?${params.toString()}`,\n undefined,\n { fallback: { items: [] } },\n )\n if (!call.ok) {\n const message = call.result?.error || t('attachments.library.errors.load', 'Failed to load attachments.')\n throw new Error(message)\n }\n const payload = call.result ?? { items: [] }\n const nextItems = Array.isArray(payload.items) ? payload.items : []\n // `/api/attachments` clamps the requested page to the last one \u2014 for the\n // offset and for the page it echoes back \u2014 so a request past the end does\n // not come back short, it comes back full, re-serving the last page. Take\n // an echoed page below the one asked for as the end of the list: appending\n // it would duplicate every row, and its clamped page number would never\n // advance, leaving the affordance with no terminating state.\n const returnedPage = typeof payload.page === 'number' ? payload.page : targetPage\n const servedRequestedPage = returnedPage >= targetPage\n setItems((current) => {\n if (replace) return nextItems\n if (!servedRequestedPage) return current\n const merged = new Map(current.map((item) => [item.id, item]))\n nextItems.forEach((item) => merged.set(item.id, item))\n return Array.from(merged.values())\n })\n setPage(returnedPage)\n setHasMore(servedRequestedPage && hasMoreFromPage(nextItems.length, PAGE_SIZE))\n } catch (err: any) {\n setError(err?.message || t('attachments.library.errors.load', 'Failed to load attachments.'))\n } finally {\n setLoading(false)\n }\n }, [entityId, recordId, t])\n\n React.useEffect(() => {\n if (recordId) {\n void load()\n } else {\n setItems([])\n setPage(1)\n setHasMore(false)\n setError(null)\n }\n }, [load, recordId])\n\n const acceptFiles = React.useCallback(\n async (files: FileList | null) => {\n if (!files || !files.length || !recordId || uploadingRef.current) return\n uploadingRef.current = true\n setError(null)\n setIsUploading(true)\n let uploadedCount = 0\n let uploadError: string | null = null\n try {\n for (const file of Array.from(files)) {\n const fd = new FormData()\n fd.set('entityId', entityId)\n fd.set('recordId', recordId)\n fd.set('file', file)\n const call = await apiCall<{ ok?: boolean; item?: AttachmentItem; error?: string }>(\n '/api/attachments',\n { method: 'POST', body: fd },\n { fallback: null },\n )\n if (!call.ok) {\n const message = call.result?.error || t('attachments.library.upload.failed', 'Upload failed.')\n throw new Error(message)\n }\n uploadedCount += 1\n }\n } catch (err: any) {\n uploadError = err?.message || t('attachments.library.upload.failed', 'Upload failed.')\n }\n try {\n if (uploadedCount > 0) {\n await load(1, true)\n onChanged?.()\n }\n } finally {\n if (uploadError) setError(uploadError)\n uploadingRef.current = false\n setIsUploading(false)\n if (fileInputRef.current) {\n fileInputRef.current.value = ''\n }\n }\n },\n [entityId, load, onChanged, recordId, t],\n )\n\n const handleDrop = React.useCallback(\n (event: React.DragEvent<HTMLDivElement>) => {\n event.preventDefault()\n event.stopPropagation()\n setIsDragOver(false)\n void acceptFiles(event.dataTransfer?.files ?? null)\n },\n [acceptFiles],\n )\n\n const handleDragOver = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {\n event.preventDefault()\n event.stopPropagation()\n setIsDragOver(true)\n }, [])\n\n const handleDragLeave = React.useCallback((event: React.DragEvent<HTMLDivElement>) => {\n event.preventDefault()\n event.stopPropagation()\n setIsDragOver(false)\n }, [])\n\n const openMetadataDialog = React.useCallback((item: AttachmentItem) => {\n setSelectedItem(item)\n setMetadataOpen(true)\n }, [])\n\n const openDeleteDialog = React.useCallback((item: AttachmentItem) => {\n setDeleteTarget(item)\n setDeleteOpen(true)\n }, [])\n\n const handleDelete = React.useCallback(async () => {\n if (!deleteTarget) return\n try {\n const call = await apiCall<{ error?: string }>(\n `/api/attachments?id=${encodeURIComponent(deleteTarget.id)}`,\n { method: 'DELETE' },\n )\n if (!call.ok) {\n const message = call.result?.error || t('attachments.library.errors.delete', 'Failed to delete attachment.')\n throw new Error(message)\n }\n setDeleteOpen(false)\n setDeleteTarget(null)\n await load(1, true)\n onChanged?.()\n } catch (err: any) {\n setError(err?.message || t('attachments.library.errors.delete', 'Failed to delete attachment.'))\n }\n }, [deleteTarget, load, onChanged, t])\n\n const handleMetadataSave = React.useCallback(\n async (id: string, payload: AttachmentMetadataSavePayload) => {\n const body: Record<string, unknown> = {\n tags: payload.tags,\n assignments: payload.assignments,\n }\n if (payload.customFields && Object.keys(payload.customFields).length) {\n body.customFields = payload.customFields\n }\n const call = await apiCall<{ error?: string }>(`/api/attachments/library/${encodeURIComponent(id)}`, {\n method: 'PATCH',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n })\n if (!call.ok) {\n const message = call.result?.error || t('attachments.library.metadata.error', 'Failed to update metadata.')\n throw new Error(message)\n }\n setMetadataOpen(false)\n await load(1, true)\n onChanged?.()\n },\n [load, onChanged, t],\n )\n\n const sectionTitle = title ?? t('attachments.library.title', 'Attachments')\n const sectionDescription =\n description ?? t('attachments.library.description', 'Browse, tag, and manage every file stored in this workspace.')\n\n return (\n <div className={cn('space-y-4', className)}>\n {showHeader ? (\n <div className=\"space-y-1\">\n <div className=\"text-base font-medium\">{sectionTitle}</div>\n <div className=\"text-sm text-muted-foreground\">{sectionDescription}</div>\n </div>\n ) : null}\n\n {!recordId ? (\n <div className=\"rounded-md border border-dashed border-border/70 px-4 py-6 text-sm text-muted-foreground\">\n {t('attachments.library.upload.saveFirst', 'Save the record before uploading files.')}\n </div>\n ) : (\n <div\n className={cn(\n 'flex flex-col items-center justify-center rounded-lg border border-dashed p-6 text-center transition-colors',\n isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/30',\n )}\n onDrop={handleDrop}\n onDragOver={handleDragOver}\n onDragLeave={handleDragLeave}\n role=\"presentation\"\n >\n <Upload className=\"mx-auto h-6 w-6 text-muted-foreground\" />\n <p className=\"mt-2 text-sm text-muted-foreground\">\n {t('attachments.library.upload.dropHint', 'Drag and drop files here or click to upload.')}\n </p>\n <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"mt-4\" onClick={() => fileInputRef.current?.click()} disabled={isUploading}>\n {isUploading ? t('attachments.library.upload.submitting', 'Uploading\u2026') : t('attachments.library.upload.choose', 'Choose files')}\n </Button>\n <input\n ref={fileInputRef}\n type=\"file\"\n multiple\n className=\"hidden\"\n disabled={isUploading}\n onChange={(event) => void acceptFiles(event.target.files)}\n />\n </div>\n )}\n\n {error ? <p role=\"alert\" className=\"text-xs font-medium text-status-error-text\">{error}</p> : null}\n\n {loading ? (\n <div className=\"text-sm text-muted-foreground\">{t('attachments.library.loading', 'Loading attachments\u2026')}</div>\n ) : items.length ? (\n <div className={cn(\n 'grid gap-3',\n compact ? 'grid-cols-2 sm:grid-cols-4 lg:grid-cols-5' : 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',\n )}>\n {items.map((item) => {\n return (\n <div\n key={item.id}\n role=\"group\"\n className=\"group relative flex flex-col overflow-hidden rounded-lg border bg-card text-left transition-shadow hover:shadow-sm focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2\"\n >\n <Button\n type=\"button\"\n variant=\"ghost\"\n aria-label={item.fileName}\n onClick={() => openMetadataDialog(item)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n openMetadataDialog(item)\n }\n }}\n className=\"flex h-auto w-full flex-col items-stretch rounded-lg p-0 text-left hover:bg-transparent focus-visible:outline-none focus-visible:ring-0\"\n >\n <AttachmentVisualPreview\n fileName={item.fileName}\n mimeType={item.mimeType}\n thumbnailUrl={item.thumbnailUrl}\n className={compact ? 'aspect-[2/1] w-full' : 'aspect-[4/3] w-full'}\n />\n <div className={cn('space-y-1 w-full', compact ? 'p-2' : 'p-3')}>\n <div className={cn('truncate font-medium', compact ? 'text-xs' : 'text-sm')} title={item.fileName}>\n {item.fileName}\n </div>\n <div className=\"text-xs text-muted-foreground\">\n {formatAttachmentFileSize(item.fileSize)}\n </div>\n </div>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"absolute right-2 top-2 z-10 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100\"\n onClick={(event) => {\n event.stopPropagation()\n openDeleteDialog(item)\n }}\n aria-label={t('attachments.library.delete', 'Delete attachment')}\n >\n <Trash2 className=\"h-4 w-4 text-destructive\" />\n </Button>\n </div>\n )\n })}\n </div>\n ) : (\n <div className=\"text-sm text-muted-foreground\">\n {t('attachments.library.table.empty', 'No attachments found.')}\n </div>\n )}\n\n {items.length > 0 && hasMore ? (\n <div className=\"flex justify-center\">\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => { void load(page + 1, false) }}\n disabled={loading}\n >\n {t('attachments.library.loadMore', 'Load more')}\n </Button>\n </div>\n ) : null}\n\n <AttachmentMetadataDialog\n open={metadataOpen}\n onOpenChange={setMetadataOpen}\n item={selectedItem}\n availableTags={[]}\n onSave={handleMetadataSave}\n />\n <AttachmentDeleteDialog\n open={deleteOpen}\n onOpenChange={setDeleteOpen}\n fileName={deleteTarget?.fileName}\n onConfirm={handleDelete}\n isDeleting={false}\n />\n </div>\n )\n}\n\nexport function AttachmentsSection(props: Props) {\n const handle = ComponentReplacementHandles.section('ui.detail', 'AttachmentsSection')\n const Resolved = useRegisteredComponent<Props>(\n handle,\n AttachmentsSectionImpl as React.ComponentType<Props>,\n )\n\n return (\n <div data-component-handle={handle}>\n <Resolved {...props} />\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -25,6 +25,7 @@ import { ComponentReplacementHandles } from "@open-mercato/shared/modules/widget
|
|
|
25
25
|
import { MarkdownPreview } from "../markdown/index.js";
|
|
26
26
|
import { useRegisteredComponent } from "../injection/useRegisteredComponent.js";
|
|
27
27
|
const NOTES_PAGE_SIZE = 20;
|
|
28
|
+
const noteBodyClassName = "break-words text-foreground [&>*]:mb-2 [&>*:last-child]:mb-0 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-xs";
|
|
28
29
|
const isTestEnv = typeof process !== "undefined" && (process.env.NODE_ENV === "test" || typeof process.env.JEST_WORKER_ID !== "undefined");
|
|
29
30
|
let markdownPluginsPromise = null;
|
|
30
31
|
async function loadMarkdownPlugins() {
|
|
@@ -137,7 +138,10 @@ function NotesSectionImpl({
|
|
|
137
138
|
disableMarkdown,
|
|
138
139
|
forceMarkdown,
|
|
139
140
|
hideMarkdownToggle,
|
|
140
|
-
disableAppearance
|
|
141
|
+
disableAppearance,
|
|
142
|
+
canEditNote,
|
|
143
|
+
canDeleteNote,
|
|
144
|
+
readOnly = false
|
|
141
145
|
}) {
|
|
142
146
|
const { confirm, ConfirmDialogElement } = useConfirmDialog();
|
|
143
147
|
const t = React.useMemo(() => translator ?? ((key, fallback) => fallback ?? key), [translator]);
|
|
@@ -255,7 +259,7 @@ function NotesSectionImpl({
|
|
|
255
259
|
const textareaRef = React.useRef(null);
|
|
256
260
|
const formRef = React.useRef(null);
|
|
257
261
|
const focusComposer = React.useCallback(() => {
|
|
258
|
-
if (!hasEntity) return;
|
|
262
|
+
if (!hasEntity || readOnly) return;
|
|
259
263
|
setComposerOpen(true);
|
|
260
264
|
window.requestAnimationFrame(() => {
|
|
261
265
|
if (isMarkdownActive) {
|
|
@@ -271,7 +275,7 @@ function NotesSectionImpl({
|
|
|
271
275
|
element.focus();
|
|
272
276
|
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
273
277
|
});
|
|
274
|
-
}, [formRef, hasEntity, isMarkdownActive]);
|
|
278
|
+
}, [formRef, hasEntity, isMarkdownActive, readOnly]);
|
|
275
279
|
const [appearanceDialogState, setAppearanceDialogState] = React.useState(null);
|
|
276
280
|
const [appearanceDialogSaving, setAppearanceDialogSaving] = React.useState(false);
|
|
277
281
|
const [appearanceDialogError, setAppearanceDialogError] = React.useState(null);
|
|
@@ -356,7 +360,7 @@ function NotesSectionImpl({
|
|
|
356
360
|
}, [writeMarkdownPreference]);
|
|
357
361
|
React.useEffect(() => {
|
|
358
362
|
if (!onActionChange) return;
|
|
359
|
-
if (!notes.length) {
|
|
363
|
+
if (!notes.length || readOnly) {
|
|
360
364
|
onActionChange(null);
|
|
361
365
|
return;
|
|
362
366
|
}
|
|
@@ -367,7 +371,7 @@ function NotesSectionImpl({
|
|
|
367
371
|
icon: /* @__PURE__ */ jsx(Plus, { className: "mr-2 h-4 w-4" })
|
|
368
372
|
});
|
|
369
373
|
return () => onActionChange(null);
|
|
370
|
-
}, [onActionChange, addActionLabel, focusComposer, hasEntity, isLoading, isSubmitting, notes.length]);
|
|
374
|
+
}, [onActionChange, addActionLabel, focusComposer, hasEntity, isLoading, isSubmitting, notes.length, readOnly]);
|
|
371
375
|
const adjustTextareaSize = React.useCallback((element) => {
|
|
372
376
|
if (!element) return;
|
|
373
377
|
element.style.height = "auto";
|
|
@@ -398,12 +402,12 @@ function NotesSectionImpl({
|
|
|
398
402
|
});
|
|
399
403
|
}, [notes.length, pagedMode]);
|
|
400
404
|
React.useEffect(() => {
|
|
401
|
-
if (hasEntity) return;
|
|
405
|
+
if (hasEntity && !readOnly) return;
|
|
402
406
|
setComposerOpen(false);
|
|
403
407
|
setDraftBody("");
|
|
404
408
|
setDraftIcon(null);
|
|
405
409
|
setDraftColor(null);
|
|
406
|
-
}, [hasEntity]);
|
|
410
|
+
}, [hasEntity, readOnly]);
|
|
407
411
|
const visibleNotes = React.useMemo(
|
|
408
412
|
() => pagedMode ? notes : notes.slice(0, visibleCount),
|
|
409
413
|
[notes, pagedMode, visibleCount]
|
|
@@ -888,7 +892,7 @@ function NotesSectionImpl({
|
|
|
888
892
|
),
|
|
889
893
|
loadError ? /* @__PURE__ */ jsx(ErrorMessage, { label: loadError, className: "mt-3" }) : null,
|
|
890
894
|
/* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
|
|
891
|
-
!composerOpen && hasVisibleNotes && !onActionChange ? /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsxs(
|
|
895
|
+
!composerOpen && hasVisibleNotes && !onActionChange && !readOnly ? /* @__PURE__ */ jsx("div", { className: "flex justify-end", children: /* @__PURE__ */ jsxs(
|
|
892
896
|
Button,
|
|
893
897
|
{
|
|
894
898
|
type: "button",
|
|
@@ -911,11 +915,14 @@ function NotesSectionImpl({
|
|
|
911
915
|
) : hasVisibleNotes ? visibleNotes.map((note) => {
|
|
912
916
|
const author = noteAuthorLabel(note);
|
|
913
917
|
const isAppearanceSaving = appearanceDialogSaving && editingAppearanceNoteId === note.id;
|
|
914
|
-
const
|
|
918
|
+
const canEdit = !readOnly && (canEditNote ? canEditNote(note) : true);
|
|
919
|
+
const canDelete = !readOnly && (canDeleteNote ? canDeleteNote(note) : true);
|
|
920
|
+
const isEditingContent = canEdit && contentEditor.id === note.id;
|
|
915
921
|
const displayIcon = note.appearanceIcon ?? null;
|
|
916
922
|
const displayColor = note.appearanceColor ?? null;
|
|
917
923
|
const timestampValue = note.createdAt;
|
|
918
924
|
const fallbackTimestampLabel = formatDateTime(note.createdAt) ?? emptyLabel;
|
|
925
|
+
const contextLabel = note.contextLabel && note.contextLabel.length ? note.contextLabel : null;
|
|
919
926
|
return /* @__PURE__ */ jsxs("div", { className: "group space-y-2 rounded-lg border bg-card p-4", children: [
|
|
920
927
|
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [
|
|
921
928
|
/* @__PURE__ */ jsxs("div", { className: "space-y-1", children: [
|
|
@@ -941,14 +948,18 @@ function NotesSectionImpl({
|
|
|
941
948
|
children: note.dealTitle && note.dealTitle.length ? note.dealTitle : label("linkedDeal", "Linked deal")
|
|
942
949
|
}
|
|
943
950
|
)
|
|
951
|
+
] }) : null,
|
|
952
|
+
contextLabel ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
|
|
953
|
+
/* @__PURE__ */ jsx(ArrowUpRightSquare, { className: "h-3.5 w-3.5" }),
|
|
954
|
+
note.contextHref ? /* @__PURE__ */ jsx("a", { href: note.contextHref, className: "font-medium text-foreground hover:underline", children: contextLabel }) : /* @__PURE__ */ jsx("span", { className: "font-medium text-foreground", children: contextLabel })
|
|
944
955
|
] }) : null
|
|
945
956
|
] }),
|
|
946
|
-
/* @__PURE__ */ jsxs(
|
|
957
|
+
canEdit || canDelete ? /* @__PURE__ */ jsxs(
|
|
947
958
|
"div",
|
|
948
959
|
{
|
|
949
960
|
className: `flex items-center gap-2 transition-opacity ${isEditingContent ? "opacity-100" : "opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-within:opacity-100"}`,
|
|
950
961
|
children: [
|
|
951
|
-
/* @__PURE__ */ jsx(
|
|
962
|
+
canEdit ? /* @__PURE__ */ jsx(
|
|
952
963
|
Button,
|
|
953
964
|
{
|
|
954
965
|
type: "button",
|
|
@@ -957,8 +968,8 @@ function NotesSectionImpl({
|
|
|
957
968
|
onClick: () => setContentEditor({ id: note.id, value: note.body }),
|
|
958
969
|
children: /* @__PURE__ */ jsx(Pencil, { className: "h-4 w-4" })
|
|
959
970
|
}
|
|
960
|
-
),
|
|
961
|
-
showAppearanceControls ? /* @__PURE__ */ jsx(
|
|
971
|
+
) : null,
|
|
972
|
+
showAppearanceControls && canEdit ? /* @__PURE__ */ jsx(
|
|
962
973
|
Button,
|
|
963
974
|
{
|
|
964
975
|
type: "button",
|
|
@@ -978,7 +989,7 @@ function NotesSectionImpl({
|
|
|
978
989
|
children: isAppearanceSaving ? /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }) : /* @__PURE__ */ jsx(Palette, { className: "h-4 w-4" })
|
|
979
990
|
}
|
|
980
991
|
) : null,
|
|
981
|
-
/* @__PURE__ */ jsx(
|
|
992
|
+
canDelete ? /* @__PURE__ */ jsx(
|
|
982
993
|
Button,
|
|
983
994
|
{
|
|
984
995
|
type: "button",
|
|
@@ -991,10 +1002,10 @@ function NotesSectionImpl({
|
|
|
991
1002
|
disabled: deletingNoteId === note.id,
|
|
992
1003
|
children: deletingNoteId === note.id ? /* @__PURE__ */ jsx("span", { className: "relative flex h-4 w-4 items-center justify-center text-destructive", children: /* @__PURE__ */ jsx("span", { className: "absolute h-4 w-4 animate-spin rounded-full border border-destructive border-t-transparent" }) }) : /* @__PURE__ */ jsx(Trash2, { className: "h-4 w-4" })
|
|
993
1004
|
}
|
|
994
|
-
)
|
|
1005
|
+
) : null
|
|
995
1006
|
]
|
|
996
1007
|
}
|
|
997
|
-
)
|
|
1008
|
+
) : null
|
|
998
1009
|
] }),
|
|
999
1010
|
isEditingContent ? /* @__PURE__ */ jsxs("div", { className: "space-y-2", onKeyDown: handleContentEditorKeyDown, children: [
|
|
1000
1011
|
/* @__PURE__ */ jsx(
|
|
@@ -1043,7 +1054,7 @@ function NotesSectionImpl({
|
|
|
1043
1054
|
}
|
|
1044
1055
|
)
|
|
1045
1056
|
] })
|
|
1046
|
-
] }) : /* @__PURE__ */ jsx(
|
|
1057
|
+
] }) : canEdit ? /* @__PURE__ */ jsx(
|
|
1047
1058
|
"div",
|
|
1048
1059
|
{
|
|
1049
1060
|
role: "button",
|
|
@@ -1055,19 +1066,26 @@ function NotesSectionImpl({
|
|
|
1055
1066
|
MarkdownPreview,
|
|
1056
1067
|
{
|
|
1057
1068
|
remarkPlugins: markdownPlugins,
|
|
1058
|
-
className:
|
|
1069
|
+
className: noteBodyClassName,
|
|
1059
1070
|
children: note.body
|
|
1060
1071
|
}
|
|
1061
1072
|
)
|
|
1062
1073
|
}
|
|
1063
|
-
)
|
|
1074
|
+
) : /* @__PURE__ */ jsx("div", { className: "text-sm", children: /* @__PURE__ */ jsx(
|
|
1075
|
+
MarkdownPreview,
|
|
1076
|
+
{
|
|
1077
|
+
remarkPlugins: markdownPlugins,
|
|
1078
|
+
className: noteBodyClassName,
|
|
1079
|
+
children: note.body
|
|
1080
|
+
}
|
|
1081
|
+
) })
|
|
1064
1082
|
] }, note.id);
|
|
1065
1083
|
}) : composerOpen ? null : /* @__PURE__ */ jsx(
|
|
1066
1084
|
TabEmptyState,
|
|
1067
1085
|
{
|
|
1068
1086
|
title: emptyState.title,
|
|
1069
1087
|
description: emptyState.description,
|
|
1070
|
-
action: {
|
|
1088
|
+
action: readOnly ? void 0 : {
|
|
1071
1089
|
label: emptyState.actionLabel,
|
|
1072
1090
|
onClick: focusComposer,
|
|
1073
1091
|
disabled: isSubmitting || !hasEntity
|