@open-mercato/core 0.10.1-develop.7874.1.67e097d23a → 0.10.1-develop.7883.1.44ac2125f5
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/modules/attachments/lib/transferClient.js +72 -0
- package/dist/modules/attachments/lib/transferClient.js.map +7 -0
- package/dist/modules/attachments/lib/transferLimits.js +5 -0
- package/dist/modules/attachments/lib/transferLimits.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/create/page.js +23 -26
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/create/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/create/page.js +31 -23
- package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
- package/dist/modules/catalog/components/products/ProductsDataTable.js +7 -8
- package/dist/modules/catalog/components/products/ProductsDataTable.js.map +2 -2
- package/dist/modules/catalog/components/products/mediaTransfer.js +27 -0
- package/dist/modules/catalog/components/products/mediaTransfer.js.map +7 -0
- package/dist/modules/customer_groups/backend/customer-groups/page.js +5 -3
- package/dist/modules/customer_groups/backend/customer-groups/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +4 -4
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +5 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +43 -31
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/push_notifications/backend/push_notifications/page.js +8 -13
- package/dist/modules/push_notifications/backend/push_notifications/page.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/lib/transferClient.ts +87 -0
- package/src/modules/attachments/lib/transferLimits.ts +1 -0
- package/src/modules/catalog/backend/catalog/products/[productId]/variants/create/page.tsx +23 -32
- package/src/modules/catalog/backend/catalog/products/create/page.tsx +31 -28
- package/src/modules/catalog/components/products/ProductsDataTable.tsx +7 -8
- package/src/modules/catalog/components/products/mediaTransfer.ts +40 -0
- package/src/modules/catalog/i18n/de.json +2 -0
- package/src/modules/catalog/i18n/en.json +2 -0
- package/src/modules/catalog/i18n/es.json +2 -0
- package/src/modules/catalog/i18n/ko.json +2 -0
- package/src/modules/catalog/i18n/pl.json +2 -0
- package/src/modules/customer_groups/backend/customer-groups/page.tsx +5 -3
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +4 -4
- package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +6 -2
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +45 -32
- package/src/modules/push_notifications/backend/push_notifications/page.tsx +8 -13
|
@@ -8,7 +8,8 @@ import { StatusBadge } from "@open-mercato/ui/primitives/status-badge";
|
|
|
8
8
|
import { apiCall } from "@open-mercato/ui/backend/utils/apiCall";
|
|
9
9
|
import { flash } from "@open-mercato/ui/backend/FlashMessages";
|
|
10
10
|
import { useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
|
|
11
|
-
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
11
|
+
import { useT, useLocale } from "@open-mercato/shared/lib/i18n/context";
|
|
12
|
+
import { formatDisplayDateTime } from "@open-mercato/ui/primitives/date-format";
|
|
12
13
|
const statusVariant = {
|
|
13
14
|
pending: "info",
|
|
14
15
|
sending: "info",
|
|
@@ -17,15 +18,8 @@ const statusVariant = {
|
|
|
17
18
|
skipped: "neutral",
|
|
18
19
|
expired: "warning"
|
|
19
20
|
};
|
|
20
|
-
function formatDate(value, t) {
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
const date = new Date(value);
|
|
24
|
-
if (Number.isNaN(date.getTime())) return t("push_notifications.deliveries.noValue");
|
|
25
|
-
return date.toLocaleString();
|
|
26
|
-
} catch {
|
|
27
|
-
return t("push_notifications.deliveries.noValue");
|
|
28
|
-
}
|
|
21
|
+
function formatDate(value, locale, t) {
|
|
22
|
+
return formatDisplayDateTime(value, locale) ?? t("push_notifications.deliveries.noValue");
|
|
29
23
|
}
|
|
30
24
|
function PushDeliveriesListPage() {
|
|
31
25
|
const [rows, setRows] = React.useState([]);
|
|
@@ -36,6 +30,7 @@ function PushDeliveriesListPage() {
|
|
|
36
30
|
const [isLoading, setIsLoading] = React.useState(true);
|
|
37
31
|
const scopeVersion = useOrganizationScopeVersion();
|
|
38
32
|
const t = useT();
|
|
33
|
+
const dateLocale = useLocale();
|
|
39
34
|
const [filterValues, setFilterValues] = React.useState({});
|
|
40
35
|
const [userOptions, setUserOptions] = React.useState([]);
|
|
41
36
|
const loadUserOptions = React.useCallback(async (query) => {
|
|
@@ -175,14 +170,14 @@ function PushDeliveriesListPage() {
|
|
|
175
170
|
{
|
|
176
171
|
accessorKey: "created_at",
|
|
177
172
|
header: t("push_notifications.deliveries.columns.created"),
|
|
178
|
-
cell: ({ row }) => formatDate(row.original.created_at, t)
|
|
173
|
+
cell: ({ row }) => formatDate(row.original.created_at, dateLocale, t)
|
|
179
174
|
},
|
|
180
175
|
{
|
|
181
176
|
accessorKey: "sent_at",
|
|
182
177
|
header: t("push_notifications.deliveries.columns.sent"),
|
|
183
|
-
cell: ({ row }) => formatDate(row.original.sent_at, t)
|
|
178
|
+
cell: ({ row }) => formatDate(row.original.sent_at, dateLocale, t)
|
|
184
179
|
}
|
|
185
|
-
], [t, userLabelById]);
|
|
180
|
+
], [t, dateLocale, userLabelById]);
|
|
186
181
|
return /* @__PURE__ */ jsx(Page, { children: /* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(
|
|
187
182
|
DataTable,
|
|
188
183
|
{
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/push_notifications/backend/push_notifications/page.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { StatusBadge, type StatusMap } from '@open-mercato/ui/primitives/status-badge'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'\n\ntype PushDeliveryStatus = 'pending' | 'sending' | 'sent' | 'failed' | 'skipped' | 'expired'\n\ntype Row = {\n id: string\n notification_type_id: string\n user_id: string\n provider: string\n token_snapshot: string\n status: PushDeliveryStatus\n attempts: number\n last_error: string | null\n created_at: string | null\n sent_at: string | null\n}\n\ntype ResponsePayload = {\n items: Row[]\n total: number\n page?: number\n pageSize?: number\n totalPages: number\n totalIsCapped?: boolean\n}\n\nconst statusVariant: StatusMap<PushDeliveryStatus> = {\n pending: 'info',\n sending: 'info',\n sent: 'success',\n failed: 'error',\n skipped: 'neutral',\n expired: 'warning',\n}\n\nfunction formatDate(value: string | null, t: (key: string) => string) {\n if (!value) return t('push_notifications.deliveries.noValue')\n try {\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return t('push_notifications.deliveries.noValue')\n return date.toLocaleString()\n } catch {\n return t('push_notifications.deliveries.noValue')\n }\n}\n\nexport default function PushDeliveriesListPage() {\n const [rows, setRows] = React.useState<Row[]>([])\n const [page, setPage] = React.useState(1)\n const [total, setTotal] = React.useState(0)\n const [totalPages, setTotalPages] = React.useState(1)\n const [totalIsCapped, setTotalIsCapped] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const scopeVersion = useOrganizationScopeVersion()\n const t = useT()\n const [filterValues, setFilterValues] = React.useState<FilterValues>({})\n const [userOptions, setUserOptions] = React.useState<{ value: string; label: string; description?: string | null }[]>([])\n\n // Filter deliveries by recipient picked via name/email search instead of a raw UUID (mirrors the\n // devices list). Admins without auth.users.list degrade gracefully to no options.\n const loadUserOptions = React.useCallback(async (query?: string) => {\n const params = new URLSearchParams()\n params.set('page', '1')\n params.set('pageSize', '20')\n if (query && query.trim().length > 0) params.set('search', query.trim())\n const call = await apiCall<{ items?: { id: string; name?: string | null; email?: string | null }[] }>(\n `/api/auth/users?${params.toString()}`,\n { headers: { 'x-om-forbidden-redirect': '0' } },\n { fallback: null },\n ).catch(() => null)\n if (!call || !call.ok) return []\n const next = (call.result?.items ?? []).flatMap((item) => {\n if (!item || typeof item.id !== 'string' || !item.id.trim()) return []\n const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null\n const email = typeof item.email === 'string' && item.email.trim() ? item.email.trim() : null\n const label = name ?? email ?? item.id\n return [{ value: item.id, label, description: email && email !== label ? email : null }]\n })\n setUserOptions((prev) => {\n const map = new Map(prev.map((opt) => [opt.value, opt]))\n for (const opt of next) map.set(opt.value, opt)\n return Array.from(map.values())\n })\n return next\n }, [])\n\n React.useEffect(() => { void loadUserOptions() }, [loadUserOptions, scopeVersion])\n\n // Reuse the picker cache to label the User column; rows whose owner isn't cached still show the id.\n const userLabelById = React.useMemo(\n () => new Map(userOptions.map((opt) => [opt.value, opt.label])),\n [userOptions],\n )\n\n const filters = React.useMemo<FilterDef[]>(() => [\n {\n id: 'status',\n label: t('push_notifications.deliveries.columns.status'),\n type: 'select',\n options: [\n { value: 'pending', label: t('push_notifications.deliveries.status.pending') },\n { value: 'sending', label: t('push_notifications.deliveries.status.sending') },\n { value: 'sent', label: t('push_notifications.deliveries.status.sent') },\n { value: 'failed', label: t('push_notifications.deliveries.status.failed') },\n { value: 'skipped', label: t('push_notifications.deliveries.status.skipped') },\n { value: 'expired', label: t('push_notifications.deliveries.status.expired') },\n ],\n },\n {\n id: 'userId',\n label: t('push_notifications.deliveries.columns.user'),\n type: 'combobox',\n options: userOptions,\n loadOptions: loadUserOptions,\n },\n // A single created-at range picker (ISO `yyyy-MM-dd`) that maps to the ?from/?to query params.\n { id: 'createdRange', label: t('push_notifications.deliveries.filters.created'), type: 'dateRange' },\n ], [t, userOptions, loadUserOptions])\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n try {\n const params = new URLSearchParams()\n params.set('page', String(page))\n params.set('pageSize', '50')\n const status = typeof filterValues.status === 'string' ? filterValues.status.trim() : ''\n const userId = typeof filterValues.userId === 'string' ? filterValues.userId.trim() : ''\n const createdRange = (filterValues.createdRange ?? {}) as { from?: string; to?: string }\n const from = typeof createdRange.from === 'string' ? createdRange.from.trim() : ''\n const to = typeof createdRange.to === 'string' ? createdRange.to.trim() : ''\n if (status) params.set('status', status)\n if (userId) params.set('userId', userId)\n if (from) params.set('from', from)\n if (to) params.set('to', to)\n const fallback: ResponsePayload = { items: [], total: 0, page, totalPages: 1 }\n const call = await apiCall<ResponsePayload>(`/api/push_notifications/deliveries?${params.toString()}`, undefined, { fallback })\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('push_notifications.deliveries.error.loadFailed')\n flash(message, 'error')\n return\n }\n const payload = call.result ?? fallback\n if (!cancelled) {\n setRows(Array.isArray(payload.items) ? payload.items : [])\n setTotal(payload.total || 0)\n setTotalPages(payload.totalPages || 1)\n setTotalIsCapped(payload?.totalIsCapped === true)\n }\n } catch (error) {\n if (!cancelled) {\n const message = error instanceof Error ? error.message : t('push_notifications.deliveries.error.loadFailed')\n flash(message, 'error')\n }\n } finally {\n if (!cancelled) setIsLoading(false)\n }\n }\n load()\n return () => { cancelled = true }\n }, [page, scopeVersion, filterValues, t])\n\n const columns = React.useMemo<ColumnDef<Row>[]>(() => [\n {\n accessorKey: 'status',\n header: t('push_notifications.deliveries.columns.status'),\n cell: ({ row }) => (\n <StatusBadge variant={statusVariant[row.original.status] ?? 'neutral'} dot>\n {t(`push_notifications.deliveries.status.${row.original.status}`)}\n </StatusBadge>\n ),\n },\n { accessorKey: 'notification_type_id', header: t('push_notifications.deliveries.columns.type') },\n {\n accessorKey: 'user_id',\n header: t('push_notifications.deliveries.columns.user'),\n cell: ({ row }) => {\n const userId = row.original.user_id\n const label = userLabelById.get(userId)\n return (\n // Stop the click bubbling to the row, whose default action opens the delivery detail.\n <Link\n href={`/backend/users/${encodeURIComponent(userId)}/edit`}\n className=\"text-primary hover:underline\"\n onClick={(e) => e.stopPropagation()}\n >\n {label ?? <code className=\"text-xs\">{userId}</code>}\n </Link>\n )\n },\n },\n { accessorKey: 'provider', header: t('push_notifications.deliveries.columns.provider') },\n {\n accessorKey: 'attempts',\n header: t('push_notifications.deliveries.columns.attempts'),\n },\n {\n accessorKey: 'created_at',\n header: t('push_notifications.deliveries.columns.created'),\n cell: ({ row }) => formatDate(row.original.created_at, t),\n },\n {\n accessorKey: 'sent_at',\n header: t('push_notifications.deliveries.columns.sent'),\n cell: ({ row }) => formatDate(row.original.sent_at, t),\n },\n ], [t, userLabelById])\n\n return (\n <Page>\n <PageBody>\n <DataTable\n title={t('push_notifications.deliveries.title')}\n titleHeadingLevel={1}\n columns={columns}\n data={rows}\n filters={filters}\n filterValues={filterValues}\n onFiltersApply={(values) => { setFilterValues(values); setPage(1) }}\n onFiltersClear={() => { setFilterValues({}); setPage(1) }}\n perspective={{ tableId: 'push_notifications.deliveries' }}\n onRowClick={(row) => { window.location.href = `/backend/push_notifications/${row.id}` }}\n pagination={{ page, pageSize: 50, total, totalPages, totalIsCapped, onPageChange: setPage }}\n isLoading={isLoading}\n emptyState={t('push_notifications.deliveries.empty')}\n />\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { StatusBadge, type StatusMap } from '@open-mercato/ui/primitives/status-badge'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'\nimport { formatDisplayDateTime } from '@open-mercato/ui/primitives/date-format'\nimport type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'\n\ntype PushDeliveryStatus = 'pending' | 'sending' | 'sent' | 'failed' | 'skipped' | 'expired'\n\ntype Row = {\n id: string\n notification_type_id: string\n user_id: string\n provider: string\n token_snapshot: string\n status: PushDeliveryStatus\n attempts: number\n last_error: string | null\n created_at: string | null\n sent_at: string | null\n}\n\ntype ResponsePayload = {\n items: Row[]\n total: number\n page?: number\n pageSize?: number\n totalPages: number\n totalIsCapped?: boolean\n}\n\nconst statusVariant: StatusMap<PushDeliveryStatus> = {\n pending: 'info',\n sending: 'info',\n sent: 'success',\n failed: 'error',\n skipped: 'neutral',\n expired: 'warning',\n}\n\nfunction formatDate(value: string | null, locale: string | undefined, t: (key: string) => string) {\n return formatDisplayDateTime(value, locale) ?? t('push_notifications.deliveries.noValue')\n}\n\nexport default function PushDeliveriesListPage() {\n const [rows, setRows] = React.useState<Row[]>([])\n const [page, setPage] = React.useState(1)\n const [total, setTotal] = React.useState(0)\n const [totalPages, setTotalPages] = React.useState(1)\n const [totalIsCapped, setTotalIsCapped] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const scopeVersion = useOrganizationScopeVersion()\n const t = useT()\n const dateLocale = useLocale()\n const [filterValues, setFilterValues] = React.useState<FilterValues>({})\n const [userOptions, setUserOptions] = React.useState<{ value: string; label: string; description?: string | null }[]>([])\n\n // Filter deliveries by recipient picked via name/email search instead of a raw UUID (mirrors the\n // devices list). Admins without auth.users.list degrade gracefully to no options.\n const loadUserOptions = React.useCallback(async (query?: string) => {\n const params = new URLSearchParams()\n params.set('page', '1')\n params.set('pageSize', '20')\n if (query && query.trim().length > 0) params.set('search', query.trim())\n const call = await apiCall<{ items?: { id: string; name?: string | null; email?: string | null }[] }>(\n `/api/auth/users?${params.toString()}`,\n { headers: { 'x-om-forbidden-redirect': '0' } },\n { fallback: null },\n ).catch(() => null)\n if (!call || !call.ok) return []\n const next = (call.result?.items ?? []).flatMap((item) => {\n if (!item || typeof item.id !== 'string' || !item.id.trim()) return []\n const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null\n const email = typeof item.email === 'string' && item.email.trim() ? item.email.trim() : null\n const label = name ?? email ?? item.id\n return [{ value: item.id, label, description: email && email !== label ? email : null }]\n })\n setUserOptions((prev) => {\n const map = new Map(prev.map((opt) => [opt.value, opt]))\n for (const opt of next) map.set(opt.value, opt)\n return Array.from(map.values())\n })\n return next\n }, [])\n\n React.useEffect(() => { void loadUserOptions() }, [loadUserOptions, scopeVersion])\n\n // Reuse the picker cache to label the User column; rows whose owner isn't cached still show the id.\n const userLabelById = React.useMemo(\n () => new Map(userOptions.map((opt) => [opt.value, opt.label])),\n [userOptions],\n )\n\n const filters = React.useMemo<FilterDef[]>(() => [\n {\n id: 'status',\n label: t('push_notifications.deliveries.columns.status'),\n type: 'select',\n options: [\n { value: 'pending', label: t('push_notifications.deliveries.status.pending') },\n { value: 'sending', label: t('push_notifications.deliveries.status.sending') },\n { value: 'sent', label: t('push_notifications.deliveries.status.sent') },\n { value: 'failed', label: t('push_notifications.deliveries.status.failed') },\n { value: 'skipped', label: t('push_notifications.deliveries.status.skipped') },\n { value: 'expired', label: t('push_notifications.deliveries.status.expired') },\n ],\n },\n {\n id: 'userId',\n label: t('push_notifications.deliveries.columns.user'),\n type: 'combobox',\n options: userOptions,\n loadOptions: loadUserOptions,\n },\n // A single created-at range picker (ISO `yyyy-MM-dd`) that maps to the ?from/?to query params.\n { id: 'createdRange', label: t('push_notifications.deliveries.filters.created'), type: 'dateRange' },\n ], [t, userOptions, loadUserOptions])\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n try {\n const params = new URLSearchParams()\n params.set('page', String(page))\n params.set('pageSize', '50')\n const status = typeof filterValues.status === 'string' ? filterValues.status.trim() : ''\n const userId = typeof filterValues.userId === 'string' ? filterValues.userId.trim() : ''\n const createdRange = (filterValues.createdRange ?? {}) as { from?: string; to?: string }\n const from = typeof createdRange.from === 'string' ? createdRange.from.trim() : ''\n const to = typeof createdRange.to === 'string' ? createdRange.to.trim() : ''\n if (status) params.set('status', status)\n if (userId) params.set('userId', userId)\n if (from) params.set('from', from)\n if (to) params.set('to', to)\n const fallback: ResponsePayload = { items: [], total: 0, page, totalPages: 1 }\n const call = await apiCall<ResponsePayload>(`/api/push_notifications/deliveries?${params.toString()}`, undefined, { fallback })\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('push_notifications.deliveries.error.loadFailed')\n flash(message, 'error')\n return\n }\n const payload = call.result ?? fallback\n if (!cancelled) {\n setRows(Array.isArray(payload.items) ? payload.items : [])\n setTotal(payload.total || 0)\n setTotalPages(payload.totalPages || 1)\n setTotalIsCapped(payload?.totalIsCapped === true)\n }\n } catch (error) {\n if (!cancelled) {\n const message = error instanceof Error ? error.message : t('push_notifications.deliveries.error.loadFailed')\n flash(message, 'error')\n }\n } finally {\n if (!cancelled) setIsLoading(false)\n }\n }\n load()\n return () => { cancelled = true }\n }, [page, scopeVersion, filterValues, t])\n\n const columns = React.useMemo<ColumnDef<Row>[]>(() => [\n {\n accessorKey: 'status',\n header: t('push_notifications.deliveries.columns.status'),\n cell: ({ row }) => (\n <StatusBadge variant={statusVariant[row.original.status] ?? 'neutral'} dot>\n {t(`push_notifications.deliveries.status.${row.original.status}`)}\n </StatusBadge>\n ),\n },\n { accessorKey: 'notification_type_id', header: t('push_notifications.deliveries.columns.type') },\n {\n accessorKey: 'user_id',\n header: t('push_notifications.deliveries.columns.user'),\n cell: ({ row }) => {\n const userId = row.original.user_id\n const label = userLabelById.get(userId)\n return (\n // Stop the click bubbling to the row, whose default action opens the delivery detail.\n <Link\n href={`/backend/users/${encodeURIComponent(userId)}/edit`}\n className=\"text-primary hover:underline\"\n onClick={(e) => e.stopPropagation()}\n >\n {label ?? <code className=\"text-xs\">{userId}</code>}\n </Link>\n )\n },\n },\n { accessorKey: 'provider', header: t('push_notifications.deliveries.columns.provider') },\n {\n accessorKey: 'attempts',\n header: t('push_notifications.deliveries.columns.attempts'),\n },\n {\n accessorKey: 'created_at',\n header: t('push_notifications.deliveries.columns.created'),\n cell: ({ row }) => formatDate(row.original.created_at, dateLocale, t),\n },\n {\n accessorKey: 'sent_at',\n header: t('push_notifications.deliveries.columns.sent'),\n cell: ({ row }) => formatDate(row.original.sent_at, dateLocale, t),\n },\n ], [t, dateLocale, userLabelById])\n\n return (\n <Page>\n <PageBody>\n <DataTable\n title={t('push_notifications.deliveries.title')}\n titleHeadingLevel={1}\n columns={columns}\n data={rows}\n filters={filters}\n filterValues={filterValues}\n onFiltersApply={(values) => { setFilterValues(values); setPage(1) }}\n onFiltersClear={() => { setFilterValues({}); setPage(1) }}\n perspective={{ tableId: 'push_notifications.deliveries' }}\n onRowClick={(row) => { window.location.href = `/backend/push_notifications/${row.id}` }}\n pagination={{ page, pageSize: 50, total, totalPages, totalIsCapped, onPageChange: setPage }}\n isLoading={isLoading}\n emptyState={t('push_notifications.deliveries.empty')}\n />\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7883.1.44ac2125f5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -266,16 +266,16 @@
|
|
|
266
266
|
"zod": "^4.6.6"
|
|
267
267
|
},
|
|
268
268
|
"peerDependencies": {
|
|
269
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
269
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7883.1.44ac2125f5",
|
|
270
|
+
"@open-mercato/shared": "0.10.1-develop.7883.1.44ac2125f5",
|
|
271
|
+
"@open-mercato/ui": "0.10.1-develop.7883.1.44ac2125f5",
|
|
272
272
|
"react": "^19.0.0",
|
|
273
273
|
"react-dom": "^19.0.0"
|
|
274
274
|
},
|
|
275
275
|
"devDependencies": {
|
|
276
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
276
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7883.1.44ac2125f5",
|
|
277
|
+
"@open-mercato/shared": "0.10.1-develop.7883.1.44ac2125f5",
|
|
278
|
+
"@open-mercato/ui": "0.10.1-develop.7883.1.44ac2125f5",
|
|
279
279
|
"@testing-library/dom": "^10.4.1",
|
|
280
280
|
"@testing-library/jest-dom": "^7.0.1",
|
|
281
281
|
"@testing-library/react": "^16.3.3",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
2
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
3
|
+
import { ATTACHMENT_TRANSFER_MAX_IDS } from './transferLimits'
|
|
4
|
+
|
|
5
|
+
export type TransferAttachmentsInput = {
|
|
6
|
+
entityId: string
|
|
7
|
+
attachmentIds: string[]
|
|
8
|
+
fromRecordId?: string | null
|
|
9
|
+
toRecordId: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type TransferAttachmentsResult = {
|
|
13
|
+
transferredCount: number
|
|
14
|
+
failedIds: string[]
|
|
15
|
+
unconfirmedCount: number
|
|
16
|
+
errors: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function chunkAttachmentIds(attachmentIds: string[], size = ATTACHMENT_TRANSFER_MAX_IDS): string[][] {
|
|
20
|
+
const chunks: string[][] = []
|
|
21
|
+
for (let index = 0; index < attachmentIds.length; index += size) {
|
|
22
|
+
chunks.push(attachmentIds.slice(index, index + size))
|
|
23
|
+
}
|
|
24
|
+
return chunks
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stopsRemainingChunks(error: unknown): boolean {
|
|
28
|
+
const name = typeof error === 'object' && error !== null ? (error as { name?: unknown }).name : undefined
|
|
29
|
+
return name === 'AbortError' || name === 'UnauthorizedError'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function describeError(error: unknown): string {
|
|
33
|
+
return error instanceof Error && error.message ? error.message : String(error)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function transferAttachmentsInChunks({
|
|
37
|
+
entityId,
|
|
38
|
+
attachmentIds,
|
|
39
|
+
fromRecordId,
|
|
40
|
+
toRecordId,
|
|
41
|
+
}: TransferAttachmentsInput): Promise<TransferAttachmentsResult> {
|
|
42
|
+
const result: TransferAttachmentsResult = { transferredCount: 0, failedIds: [], unconfirmedCount: 0, errors: [] }
|
|
43
|
+
const chunks = chunkAttachmentIds(attachmentIds)
|
|
44
|
+
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
|
|
45
|
+
const chunk = chunks[chunkIndex]
|
|
46
|
+
try {
|
|
47
|
+
const call = await apiCall<{ ok?: boolean; updated?: number; error?: string }>(
|
|
48
|
+
'/api/attachments/transfer',
|
|
49
|
+
{
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'content-type': 'application/json' },
|
|
52
|
+
body: JSON.stringify({
|
|
53
|
+
entityId,
|
|
54
|
+
attachmentIds: chunk,
|
|
55
|
+
...(fromRecordId ? { fromRecordId } : {}),
|
|
56
|
+
toRecordId,
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
{ fallback: null },
|
|
60
|
+
)
|
|
61
|
+
if (!call.ok) {
|
|
62
|
+
result.failedIds.push(...chunk)
|
|
63
|
+
if (call.result?.error) result.errors.push(call.result.error)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
const updated = call.result?.updated
|
|
67
|
+
const confirmed = typeof updated === 'number' && Number.isFinite(updated)
|
|
68
|
+
? Math.min(Math.max(Math.trunc(updated), 0), chunk.length)
|
|
69
|
+
: 0
|
|
70
|
+
result.transferredCount += confirmed
|
|
71
|
+
result.unconfirmedCount += chunk.length - confirmed
|
|
72
|
+
} catch (error) {
|
|
73
|
+
getTelemetryRuntime()?.reportError(error, {
|
|
74
|
+
module: 'attachments',
|
|
75
|
+
code: 'attachments.transfer_chunk_failed',
|
|
76
|
+
attributes: { chunkSize: chunk.length },
|
|
77
|
+
})
|
|
78
|
+
result.errors.push(describeError(error))
|
|
79
|
+
if (stopsRemainingChunks(error)) {
|
|
80
|
+
result.failedIds.push(...chunks.slice(chunkIndex).flat())
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
result.failedIds.push(...chunk)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const ATTACHMENT_TRANSFER_MAX_IDS = 100
|
|
@@ -35,10 +35,10 @@ import {
|
|
|
35
35
|
VariantPricesSection,
|
|
36
36
|
VariantMediaSection,
|
|
37
37
|
} from '@open-mercato/core/modules/catalog/components/products/VariantBuilder'
|
|
38
|
-
import type { ProductMediaItem } from '@open-mercato/core/modules/catalog/components/products/ProductMediaManager'
|
|
39
38
|
import { buildAttachmentImageUrl, slugifyAttachmentFileName } from '@open-mercato/core/modules/attachments/lib/imageUrls'
|
|
40
39
|
import { fetchOptionSchemaTemplate } from '../../../optionSchemaClient'
|
|
41
40
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
41
|
+
import { transferDraftMedia } from '@open-mercato/core/modules/catalog/components/products/mediaTransfer'
|
|
42
42
|
|
|
43
43
|
const logger = createLogger('catalog')
|
|
44
44
|
|
|
@@ -393,11 +393,19 @@ export default function CreateVariantPage({ params }: { params?: { productId?: s
|
|
|
393
393
|
if (!variantId) {
|
|
394
394
|
throw createCrudFormError(t('catalog.variants.form.errors.idMissing', 'Variant id missing after create.'))
|
|
395
395
|
}
|
|
396
|
-
await
|
|
396
|
+
const mediaTransfer = await transferDraftMedia({
|
|
397
|
+
entityId: E.catalog.catalog_product_variant,
|
|
397
398
|
draftId: values.mediaDraftId,
|
|
398
|
-
variantId,
|
|
399
|
+
recordId: variantId,
|
|
399
400
|
mediaItems: Array.isArray(values.mediaItems) ? values.mediaItems : [],
|
|
400
401
|
})
|
|
402
|
+
if (mediaTransfer.failedCount > 0) {
|
|
403
|
+
logger.error('attachments.transfer.failed', {
|
|
404
|
+
failed: mediaTransfer.failedCount,
|
|
405
|
+
requested: mediaTransfer.requestedCount,
|
|
406
|
+
errors: mediaTransfer.errors,
|
|
407
|
+
})
|
|
408
|
+
}
|
|
401
409
|
await syncVariantPrices({
|
|
402
410
|
priceKinds,
|
|
403
411
|
priceDrafts: values.prices ?? {},
|
|
@@ -408,7 +416,18 @@ export default function CreateVariantPage({ params }: { params?: { productId?: s
|
|
|
408
416
|
productTaxRateId,
|
|
409
417
|
productTaxRate,
|
|
410
418
|
})
|
|
411
|
-
|
|
419
|
+
if (mediaTransfer.failedCount > 0) {
|
|
420
|
+
flash(
|
|
421
|
+
t(
|
|
422
|
+
'catalog.variants.form.mediaTransferIncomplete',
|
|
423
|
+
'Variant created, but {failed} of {total} media files could not be attached.',
|
|
424
|
+
{ failed: mediaTransfer.failedCount, total: mediaTransfer.requestedCount },
|
|
425
|
+
),
|
|
426
|
+
'warning',
|
|
427
|
+
)
|
|
428
|
+
} else {
|
|
429
|
+
flash(t('catalog.variants.form.createSuccess', 'Variant created.'), 'success')
|
|
430
|
+
}
|
|
412
431
|
router.push(`/backend/catalog/products/${productId}#variants`)
|
|
413
432
|
}}
|
|
414
433
|
/>
|
|
@@ -417,34 +436,6 @@ export default function CreateVariantPage({ params }: { params?: { productId?: s
|
|
|
417
436
|
)
|
|
418
437
|
}
|
|
419
438
|
|
|
420
|
-
async function transferVariantMedia({
|
|
421
|
-
draftId,
|
|
422
|
-
variantId,
|
|
423
|
-
mediaItems,
|
|
424
|
-
}: {
|
|
425
|
-
draftId?: string | null
|
|
426
|
-
variantId: string
|
|
427
|
-
mediaItems: ProductMediaItem[]
|
|
428
|
-
}): Promise<void> {
|
|
429
|
-
if (!draftId || !variantId) return
|
|
430
|
-
const attachmentIds = mediaItems.map((item) => item.id).filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
431
|
-
if (!attachmentIds.length) return
|
|
432
|
-
await apiCall(
|
|
433
|
-
'/api/attachments/transfer',
|
|
434
|
-
{
|
|
435
|
-
method: 'POST',
|
|
436
|
-
headers: { 'content-type': 'application/json' },
|
|
437
|
-
body: JSON.stringify({
|
|
438
|
-
entityId: E.catalog.catalog_product_variant,
|
|
439
|
-
attachmentIds,
|
|
440
|
-
fromRecordId: draftId,
|
|
441
|
-
toRecordId: variantId,
|
|
442
|
-
}),
|
|
443
|
-
},
|
|
444
|
-
{ fallback: null },
|
|
445
|
-
)
|
|
446
|
-
}
|
|
447
|
-
|
|
448
439
|
async function syncVariantPrices({
|
|
449
440
|
priceKinds,
|
|
450
441
|
priceDrafts,
|
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
type ProductUnitConversionInput,
|
|
99
99
|
} from "@open-mercato/core/modules/catalog/components/products/productFormUtils";
|
|
100
100
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
101
|
+
import { transferDraftMedia } from "@open-mercato/core/modules/catalog/components/products/mediaTransfer";
|
|
101
102
|
|
|
102
103
|
const logger = createLogger('catalog')
|
|
103
104
|
|
|
@@ -419,9 +420,6 @@ export default function CreateCatalogProductPage() {
|
|
|
419
420
|
const mediaItems = Array.isArray(formValues.mediaItems)
|
|
420
421
|
? formValues.mediaItems
|
|
421
422
|
: [];
|
|
422
|
-
const attachmentIds = mediaItems
|
|
423
|
-
.map((item) => (typeof item.id === "string" ? item.id : null))
|
|
424
|
-
.filter((value): value is string => !!value);
|
|
425
423
|
const mediaDraftId =
|
|
426
424
|
typeof formValues.mediaDraftId === "string"
|
|
427
425
|
? formValues.mediaDraftId
|
|
@@ -768,27 +766,18 @@ export default function CreateCatalogProductPage() {
|
|
|
768
766
|
await createCrud("catalog/prices", pricePayload);
|
|
769
767
|
}
|
|
770
768
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
fromRecordId: mediaDraftId,
|
|
784
|
-
toRecordId: productId,
|
|
785
|
-
}),
|
|
786
|
-
},
|
|
787
|
-
{ fallback: null },
|
|
788
|
-
);
|
|
789
|
-
if (!transfer.ok) {
|
|
790
|
-
logger.error("attachments.transfer.failed", { err: transfer.result?.error });
|
|
791
|
-
}
|
|
769
|
+
const mediaTransfer = await transferDraftMedia({
|
|
770
|
+
entityId: E.catalog.catalog_product,
|
|
771
|
+
draftId: mediaDraftId,
|
|
772
|
+
recordId: productId,
|
|
773
|
+
mediaItems,
|
|
774
|
+
});
|
|
775
|
+
if (mediaTransfer.failedCount > 0) {
|
|
776
|
+
logger.error("attachments.transfer.failed", {
|
|
777
|
+
failed: mediaTransfer.failedCount,
|
|
778
|
+
requested: mediaTransfer.requestedCount,
|
|
779
|
+
errors: mediaTransfer.errors,
|
|
780
|
+
});
|
|
792
781
|
}
|
|
793
782
|
|
|
794
783
|
if (inboxDraft) {
|
|
@@ -817,10 +806,24 @@ export default function CreateCatalogProductPage() {
|
|
|
817
806
|
}
|
|
818
807
|
}
|
|
819
808
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
809
|
+
if (mediaTransfer.failedCount > 0) {
|
|
810
|
+
flash(
|
|
811
|
+
t(
|
|
812
|
+
"catalog.products.create.mediaTransferIncomplete",
|
|
813
|
+
"Product created, but {failed} of {total} media files could not be attached.",
|
|
814
|
+
{
|
|
815
|
+
failed: mediaTransfer.failedCount,
|
|
816
|
+
total: mediaTransfer.requestedCount,
|
|
817
|
+
},
|
|
818
|
+
),
|
|
819
|
+
"warning",
|
|
820
|
+
);
|
|
821
|
+
} else {
|
|
822
|
+
flash(
|
|
823
|
+
t("catalog.products.create.success", "Product created."),
|
|
824
|
+
"success",
|
|
825
|
+
);
|
|
826
|
+
}
|
|
824
827
|
if (inboxDraft) {
|
|
825
828
|
router.push(
|
|
826
829
|
`/backend/inbox-ops/proposals/${encodeURIComponent(inboxDraft.proposalId)}`,
|
|
@@ -20,7 +20,8 @@ import type { FilterOption } from '@open-mercato/ui/backend/FilterOverlay'
|
|
|
20
20
|
import { BooleanIcon } from '@open-mercato/ui/backend/ValueIcons'
|
|
21
21
|
import { markdownToPlainText } from '@open-mercato/ui/backend/markdown/markdownToPlainText'
|
|
22
22
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
23
|
-
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
23
|
+
import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
|
|
24
|
+
import { formatDisplayDateTime } from '@open-mercato/ui/primitives/date-format'
|
|
24
25
|
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
25
26
|
import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
|
|
26
27
|
import { E } from '#generated/entities.ids.generated'
|
|
@@ -92,11 +93,8 @@ type ProductsResponse = {
|
|
|
92
93
|
const PAGE_SIZE = 25
|
|
93
94
|
const ENTITY_ID = E.catalog.catalog_product
|
|
94
95
|
|
|
95
|
-
function formatDate(value
|
|
96
|
-
|
|
97
|
-
const date = new Date(value)
|
|
98
|
-
if (Number.isNaN(date.getTime())) return '—'
|
|
99
|
-
return date.toLocaleDateString()
|
|
96
|
+
function formatDate(value: string | undefined, locale: string | undefined): string {
|
|
97
|
+
return formatDisplayDateTime(value, locale) ?? '—'
|
|
100
98
|
}
|
|
101
99
|
|
|
102
100
|
function renderOffers(offers: OfferInfo[] | undefined): React.ReactNode {
|
|
@@ -172,6 +170,7 @@ export default function ProductsDataTable({
|
|
|
172
170
|
onSnapshotChange,
|
|
173
171
|
}: ProductsDataTableProps = {}) {
|
|
174
172
|
const t = useT()
|
|
173
|
+
const dateLocale = useLocale()
|
|
175
174
|
const { confirm, ConfirmDialogElement } = useConfirmDialog()
|
|
176
175
|
const scopeVersion = useOrganizationScopeVersion()
|
|
177
176
|
const [rows, setRows] = React.useState<ProductRow[]>([])
|
|
@@ -467,11 +466,11 @@ export default function ProductsDataTable({
|
|
|
467
466
|
{
|
|
468
467
|
accessorKey: 'updated_at',
|
|
469
468
|
header: t('catalog.products.table.updatedAt'),
|
|
470
|
-
cell: ({ row }) => <span className="text-xs text-muted-foreground">{formatDate(row.original.updated_at)}</span>,
|
|
469
|
+
cell: ({ row }) => <span className="text-xs text-muted-foreground">{formatDate(row.original.updated_at, dateLocale)}</span>,
|
|
471
470
|
},
|
|
472
471
|
]
|
|
473
472
|
return applyCustomFieldVisibility(base, customFieldDefs)
|
|
474
|
-
}, [customFieldDefs, productTypeLabelMap, t])
|
|
473
|
+
}, [customFieldDefs, productTypeLabelMap, t, dateLocale])
|
|
475
474
|
|
|
476
475
|
const handleSearchChange = React.useCallback((value: string) => {
|
|
477
476
|
setSearch(value)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { transferAttachmentsInChunks } from '@open-mercato/core/modules/attachments/lib/transferClient'
|
|
2
|
+
import type { ProductMediaItem } from './ProductMediaManager'
|
|
3
|
+
|
|
4
|
+
export type DraftMediaTransferInput = {
|
|
5
|
+
entityId: string
|
|
6
|
+
draftId?: string | null
|
|
7
|
+
recordId: string
|
|
8
|
+
mediaItems: ProductMediaItem[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type DraftMediaTransferResult = {
|
|
12
|
+
requestedCount: number
|
|
13
|
+
failedCount: number
|
|
14
|
+
errors: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function transferDraftMedia({
|
|
18
|
+
entityId,
|
|
19
|
+
draftId,
|
|
20
|
+
recordId,
|
|
21
|
+
mediaItems,
|
|
22
|
+
}: DraftMediaTransferInput): Promise<DraftMediaTransferResult> {
|
|
23
|
+
const attachmentIds = mediaItems
|
|
24
|
+
.map((item) => item.id)
|
|
25
|
+
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
26
|
+
if (!draftId || !recordId || !attachmentIds.length) {
|
|
27
|
+
return { requestedCount: 0, failedCount: 0, errors: [] }
|
|
28
|
+
}
|
|
29
|
+
const result = await transferAttachmentsInChunks({
|
|
30
|
+
entityId,
|
|
31
|
+
attachmentIds,
|
|
32
|
+
fromRecordId: draftId,
|
|
33
|
+
toRecordId: recordId,
|
|
34
|
+
})
|
|
35
|
+
return {
|
|
36
|
+
requestedCount: attachmentIds.length,
|
|
37
|
+
failedCount: result.failedIds.length + result.unconfirmedCount,
|
|
38
|
+
errors: result.errors,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -416,6 +416,7 @@
|
|
|
416
416
|
"catalog.products.create.errors.variant": "Variante konnte nicht erstellt werden.",
|
|
417
417
|
"catalog.products.create.fields.currency": "Hauptwährung",
|
|
418
418
|
"catalog.products.create.handleHelp": "Handle wird für URLs verwendet und muss eindeutig sein.",
|
|
419
|
+
"catalog.products.create.mediaTransferIncomplete": "Produkt erstellt, aber {failed} von {total} Mediendateien konnten nicht angehängt werden.",
|
|
419
420
|
"catalog.products.create.meta.description": "Verwalte Untertitel, Handle und Währung für deine Storefronts.",
|
|
420
421
|
"catalog.products.create.meta.handleLabel": "Handle",
|
|
421
422
|
"catalog.products.create.meta.title": "Produkt-Metadaten",
|
|
@@ -882,6 +883,7 @@
|
|
|
882
883
|
"catalog.variants.form.isDefaultLabel": "Standardvariante",
|
|
883
884
|
"catalog.variants.form.loading": "Formular wird geladen...",
|
|
884
885
|
"catalog.variants.form.media": "Medien",
|
|
886
|
+
"catalog.variants.form.mediaTransferIncomplete": "Variante erstellt, aber {failed} von {total} Mediendateien konnten nicht angehängt werden.",
|
|
885
887
|
"catalog.variants.form.nameLabel": "Name",
|
|
886
888
|
"catalog.variants.form.namePlaceholder": "z. B. Blau / Klein",
|
|
887
889
|
"catalog.variants.form.nameRequired": "Geben Sie den Variantennamen an.",
|
|
@@ -416,6 +416,7 @@
|
|
|
416
416
|
"catalog.products.create.errors.variant": "Failed to create variant.",
|
|
417
417
|
"catalog.products.create.fields.currency": "Primary currency",
|
|
418
418
|
"catalog.products.create.handleHelp": "Handle is used for URLs and must be unique.",
|
|
419
|
+
"catalog.products.create.mediaTransferIncomplete": "Product created, but {failed} of {total} media files could not be attached.",
|
|
419
420
|
"catalog.products.create.meta.description": "Manage subtitle, handle, and currency for storefronts.",
|
|
420
421
|
"catalog.products.create.meta.handleLabel": "Handle",
|
|
421
422
|
"catalog.products.create.meta.title": "Product meta",
|
|
@@ -882,6 +883,7 @@
|
|
|
882
883
|
"catalog.variants.form.isDefaultLabel": "Default variant",
|
|
883
884
|
"catalog.variants.form.loading": "Loading form...",
|
|
884
885
|
"catalog.variants.form.media": "Media",
|
|
886
|
+
"catalog.variants.form.mediaTransferIncomplete": "Variant created, but {failed} of {total} media files could not be attached.",
|
|
885
887
|
"catalog.variants.form.nameLabel": "Name",
|
|
886
888
|
"catalog.variants.form.namePlaceholder": "e.g., Blue / Small",
|
|
887
889
|
"catalog.variants.form.nameRequired": "Provide the variant name.",
|
|
@@ -416,6 +416,7 @@
|
|
|
416
416
|
"catalog.products.create.errors.variant": "No se pudo crear la variante.",
|
|
417
417
|
"catalog.products.create.fields.currency": "Moneda principal",
|
|
418
418
|
"catalog.products.create.handleHelp": "El identificador se usa en URLs y debe ser único.",
|
|
419
|
+
"catalog.products.create.mediaTransferIncomplete": "Producto creado, pero no se pudieron adjuntar {failed} de {total} archivos multimedia.",
|
|
419
420
|
"catalog.products.create.meta.description": "Administra el subtítulo, el identificador y la moneda para las vitrinas.",
|
|
420
421
|
"catalog.products.create.meta.handleLabel": "Identificador",
|
|
421
422
|
"catalog.products.create.meta.title": "Metadatos del producto",
|
|
@@ -882,6 +883,7 @@
|
|
|
882
883
|
"catalog.variants.form.isDefaultLabel": "Variante predeterminada",
|
|
883
884
|
"catalog.variants.form.loading": "Cargando formulario...",
|
|
884
885
|
"catalog.variants.form.media": "Medios",
|
|
886
|
+
"catalog.variants.form.mediaTransferIncomplete": "Variante creada, pero no se pudieron adjuntar {failed} de {total} archivos multimedia.",
|
|
885
887
|
"catalog.variants.form.nameLabel": "Nombre",
|
|
886
888
|
"catalog.variants.form.namePlaceholder": "ej., Azul / Pequeño",
|
|
887
889
|
"catalog.variants.form.nameRequired": "Ingrese el nombre de la variante.",
|
|
@@ -416,6 +416,7 @@
|
|
|
416
416
|
"catalog.products.create.errors.variant": "변형 생성에 실패했습니다.",
|
|
417
417
|
"catalog.products.create.fields.currency": "기본 통화",
|
|
418
418
|
"catalog.products.create.handleHelp": "핸들은 URL에 사용되며 고유해야 합니다.",
|
|
419
|
+
"catalog.products.create.mediaTransferIncomplete": "상품이 생성되었지만 미디어 파일 {total}개 중 {failed}개를 첨부하지 못했습니다.",
|
|
419
420
|
"catalog.products.create.meta.description": "스토어프론트에 사용할 부제목, 핸들, 통화를 관리합니다.",
|
|
420
421
|
"catalog.products.create.meta.handleLabel": "핸들",
|
|
421
422
|
"catalog.products.create.meta.title": "상품 메타 정보",
|
|
@@ -882,6 +883,7 @@
|
|
|
882
883
|
"catalog.variants.form.isDefaultLabel": "기본 변형",
|
|
883
884
|
"catalog.variants.form.loading": "폼을 불러오는 중...",
|
|
884
885
|
"catalog.variants.form.media": "미디어",
|
|
886
|
+
"catalog.variants.form.mediaTransferIncomplete": "변형이 생성되었지만 미디어 파일 {total}개 중 {failed}개를 첨부하지 못했습니다.",
|
|
885
887
|
"catalog.variants.form.nameLabel": "이름",
|
|
886
888
|
"catalog.variants.form.namePlaceholder": "예: 블루 / 스몰",
|
|
887
889
|
"catalog.variants.form.nameRequired": "변형 이름을 입력하세요.",
|
|
@@ -416,6 +416,7 @@
|
|
|
416
416
|
"catalog.products.create.errors.variant": "Nie udało się utworzyć wariantu.",
|
|
417
417
|
"catalog.products.create.fields.currency": "Główna waluta",
|
|
418
418
|
"catalog.products.create.handleHelp": "Identyfikator jest używany w adresach URL i musi być unikalny.",
|
|
419
|
+
"catalog.products.create.mediaTransferIncomplete": "Produkt utworzony, ale nie udało się dołączyć {failed} z {total} plików multimedialnych.",
|
|
419
420
|
"catalog.products.create.meta.description": "Zarządzaj podtytułem, identyfikatorem i walutą dla witryn.",
|
|
420
421
|
"catalog.products.create.meta.handleLabel": "Identyfikator",
|
|
421
422
|
"catalog.products.create.meta.title": "Metadane produktu",
|
|
@@ -882,6 +883,7 @@
|
|
|
882
883
|
"catalog.variants.form.isDefaultLabel": "Domyślny wariant",
|
|
883
884
|
"catalog.variants.form.loading": "Ładowanie formularza...",
|
|
884
885
|
"catalog.variants.form.media": "Multimedia",
|
|
886
|
+
"catalog.variants.form.mediaTransferIncomplete": "Wariant utworzony, ale nie udało się dołączyć {failed} z {total} plików multimedialnych.",
|
|
885
887
|
"catalog.variants.form.nameLabel": "Nazwa",
|
|
886
888
|
"catalog.variants.form.namePlaceholder": "np. Niebieski / Mały",
|
|
887
889
|
"catalog.variants.form.nameRequired": "Podaj nazwę wariantu.",
|
|
@@ -11,7 +11,8 @@ import { StatusBadge, type StatusMap } from '@open-mercato/ui/primitives/status-
|
|
|
11
11
|
import { Tag, type TagMap } from '@open-mercato/ui/primitives/tag'
|
|
12
12
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
13
13
|
import { Plus, GripVertical } from 'lucide-react'
|
|
14
|
-
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
14
|
+
import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
|
|
15
|
+
import { formatDisplayDateTime } from '@open-mercato/ui/primitives/date-format'
|
|
15
16
|
import { apiCall, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
16
17
|
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
17
18
|
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
@@ -117,6 +118,7 @@ function DragHandle({ id }: { id: string }) {
|
|
|
117
118
|
|
|
118
119
|
export default function CustomerGroupsPage() {
|
|
119
120
|
const t = useT()
|
|
121
|
+
const dateLocale = useLocale()
|
|
120
122
|
const { confirm: confirmDialog, ConfirmDialogElement } = useConfirmDialog()
|
|
121
123
|
const { payload: backendChromePayload, isReady: backendChromeReady } = useBackendChrome()
|
|
122
124
|
const canManage = backendChromeReady && hasFeature(backendChromePayload?.grantedFeatures, MANAGE_FEATURE)
|
|
@@ -375,10 +377,10 @@ export default function CustomerGroupsPage() {
|
|
|
375
377
|
{
|
|
376
378
|
accessorKey: 'updated_at',
|
|
377
379
|
header: t('customer_groups.groups.list.columns.updatedAt', 'Updated'),
|
|
378
|
-
cell: ({ row }) => (row.original.updated_at
|
|
380
|
+
cell: ({ row }) => formatDisplayDateTime(row.original.updated_at, dateLocale) ?? '—',
|
|
379
381
|
},
|
|
380
382
|
],
|
|
381
|
-
[t, dragReorderEnabled],
|
|
383
|
+
[t, dateLocale, dragReorderEnabled],
|
|
382
384
|
)
|
|
383
385
|
|
|
384
386
|
const filterDefs = React.useMemo<FilterDef[]>(
|