@open-mercato/core 0.6.6-develop.6205.1.109e4b6a84 → 0.6.6-develop.6227.1.2695efff30
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/currencies/backend/currencies/[id]/page.js +33 -8
- package/dist/modules/currencies/backend/currencies/[id]/page.js.map +2 -2
- package/dist/modules/currencies/backend/currencies/page.js +63 -27
- package/dist/modules/currencies/backend/currencies/page.js.map +2 -2
- package/dist/modules/currencies/backend/exchange-rates/page.js +35 -14
- package/dist/modules/currencies/backend/exchange-rates/page.js.map +2 -2
- package/dist/modules/currencies/components/CurrencyFetchingConfig.js +104 -41
- package/dist/modules/currencies/components/CurrencyFetchingConfig.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/roles/[id]/acl.js +57 -8
- package/dist/modules/customer_accounts/api/admin/roles/[id]/acl.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js +5 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/roles/page.js +30 -14
- package/dist/modules/customer_accounts/backend/customer_accounts/roles/page.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +29 -19
- package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +166 -64
- package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +2 -2
- package/dist/modules/customers/components/AddressFormatSettings.js +31 -16
- package/dist/modules/customers/components/AddressFormatSettings.js.map +2 -2
- package/dist/modules/customers/components/DictionarySettings.js +57 -31
- package/dist/modules/customers/components/DictionarySettings.js.map +2 -2
- package/dist/modules/customers/components/PipelineSettings.js +156 -76
- package/dist/modules/customers/components/PipelineSettings.js.map +2 -2
- package/dist/modules/customers/components/calendar/AgendaList.js +10 -4
- package/dist/modules/customers/components/calendar/AgendaList.js.map +2 -2
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js +18 -2
- package/dist/modules/customers/components/calendar/CalendarSettingsModal.js.map +2 -2
- package/dist/modules/customers/components/calendar/MonthGrid.js +5 -2
- package/dist/modules/customers/components/calendar/MonthGrid.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/PeopleField.js +6 -0
- package/dist/modules/customers/components/calendar/editor/PeopleField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/RelatedToField.js +2 -0
- package/dist/modules/customers/components/calendar/editor/RelatedToField.js.map +2 -2
- package/dist/modules/customers/components/calendar/editor/ScheduleSection.js +4 -1
- package/dist/modules/customers/components/calendar/editor/ScheduleSection.js.map +2 -2
- package/dist/modules/customers/lib/calendar/labels.js +33 -0
- package/dist/modules/customers/lib/calendar/labels.js.map +7 -0
- package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +32 -11
- package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
- package/dist/modules/data_sync/api/mappings/[id]/route.js +59 -0
- package/dist/modules/data_sync/api/mappings/[id]/route.js.map +2 -2
- package/dist/modules/data_sync/api/mappings/route.js +44 -0
- package/dist/modules/data_sync/api/mappings/route.js.map +2 -2
- package/dist/modules/data_sync/api/run.js +31 -0
- package/dist/modules/data_sync/api/run.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/cancel.js +31 -0
- package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
- package/dist/modules/data_sync/api/runs/[id]/retry.js +31 -0
- package/dist/modules/data_sync/api/runs/[id]/retry.js.map +2 -2
- package/dist/modules/data_sync/api/schedules/[id]/route.js +59 -3
- package/dist/modules/data_sync/api/schedules/[id]/route.js.map +2 -2
- package/dist/modules/data_sync/api/schedules/route.js +33 -4
- package/dist/modules/data_sync/api/schedules/route.js.map +2 -2
- package/dist/modules/directory/api/organization-branding/route.js +54 -2
- package/dist/modules/directory/api/organization-branding/route.js.map +2 -2
- package/dist/modules/directory/backend/directory/branding/page.js +15 -9
- package/dist/modules/directory/backend/directory/branding/page.js.map +2 -2
- package/dist/modules/directory/backend/directory/organizations/page.js +28 -9
- package/dist/modules/directory/backend/directory/organizations/page.js.map +2 -2
- package/dist/modules/directory/backend/directory/tenants/page.js +29 -13
- package/dist/modules/directory/backend/directory/tenants/page.js.map +2 -2
- package/dist/modules/entities/api/definitions.batch.js +15 -0
- package/dist/modules/entities/api/definitions.batch.js.map +2 -2
- package/dist/modules/entities/api/definitions.js +26 -0
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/api/definitions.mutation-guard.js +57 -0
- package/dist/modules/entities/api/definitions.mutation-guard.js.map +7 -0
- package/dist/modules/entities/api/definitions.restore.js +15 -0
- package/dist/modules/entities/api/definitions.restore.js.map +2 -2
- package/dist/modules/entities/api/entities.js +31 -3
- package/dist/modules/entities/api/entities.js.map +2 -2
- package/dist/modules/entities/api/records.js +18 -0
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +28 -10
- package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
- package/dist/modules/feature_toggles/api/overrides/route.js +38 -1
- package/dist/modules/feature_toggles/api/overrides/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/emails/[id]/route.js +30 -0
- package/dist/modules/inbox_ops/api/emails/[id]/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js +30 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/accept-all/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +31 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js +31 -0
- package/dist/modules/inbox_ops/api/proposals/[id]/categorize/route.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +68 -39
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
- package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js +14 -4
- package/dist/modules/notifications/frontend/NotificationSettingsPageClient.js.map +2 -2
- package/dist/modules/payment_gateways/api/cancel/route.js +37 -0
- package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/capture/route.js +37 -0
- package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/guards.js +31 -0
- package/dist/modules/payment_gateways/api/guards.js.map +7 -0
- package/dist/modules/payment_gateways/api/refund/route.js +37 -0
- package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
- package/dist/modules/payment_gateways/api/sessions/route.js +37 -0
- package/dist/modules/payment_gateways/api/sessions/route.js.map +2 -2
- package/dist/modules/planner/api/availability-date-specific.js +11 -1
- package/dist/modules/planner/api/availability-date-specific.js.map +2 -2
- package/dist/modules/planner/backend/planner/availability-rulesets/page.js +20 -3
- package/dist/modules/planner/backend/planner/availability-rulesets/page.js.map +2 -2
- package/dist/modules/planner/commands/availability-date-specific.js +16 -0
- package/dist/modules/planner/commands/availability-date-specific.js.map +2 -2
- package/dist/modules/planner/components/AvailabilityRulesEditor.js +109 -40
- package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
- package/dist/modules/query_index/api/purge.js +35 -3
- package/dist/modules/query_index/api/purge.js.map +2 -2
- package/dist/modules/query_index/api/reindex.js +41 -3
- package/dist/modules/query_index/api/reindex.js.map +2 -2
- package/dist/modules/query_index/components/QueryIndexesTable.js +57 -24
- package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
- package/dist/modules/shipping_carriers/api/shipments/route.js +31 -0
- package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
- package/dist/modules/shipping_carriers/api/tracking/refresh/route.js +55 -0
- package/dist/modules/shipping_carriers/api/tracking/refresh/route.js.map +7 -0
- package/dist/modules/shipping_carriers/data/validators.js +6 -1
- package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +28 -8
- package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
- package/dist/modules/shipping_carriers/lib/shipping-service.js +37 -7
- package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
- package/dist/modules/shipping_carriers/workers/status-poller.js +1 -1
- package/dist/modules/shipping_carriers/workers/status-poller.js.map +2 -2
- package/dist/modules/translations/components/TranslationManager.js +49 -20
- package/dist/modules/translations/components/TranslationManager.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/currencies/backend/currencies/[id]/page.tsx +40 -10
- package/src/modules/currencies/backend/currencies/page.tsx +68 -29
- package/src/modules/currencies/backend/exchange-rates/page.tsx +40 -15
- package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +110 -41
- package/src/modules/customer_accounts/api/admin/roles/[id]/acl.ts +69 -7
- package/src/modules/customer_accounts/backend/customer_accounts/roles/[id]/page.tsx +16 -8
- package/src/modules/customer_accounts/backend/customer_accounts/roles/page.tsx +32 -14
- package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +32 -20
- package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +171 -64
- package/src/modules/customers/components/AddressFormatSettings.tsx +39 -19
- package/src/modules/customers/components/DictionarySettings.tsx +63 -29
- package/src/modules/customers/components/PipelineSettings.tsx +165 -80
- package/src/modules/customers/components/calendar/AgendaList.tsx +13 -8
- package/src/modules/customers/components/calendar/CalendarSettingsModal.tsx +16 -2
- package/src/modules/customers/components/calendar/MonthGrid.tsx +5 -2
- package/src/modules/customers/components/calendar/editor/PeopleField.tsx +6 -0
- package/src/modules/customers/components/calendar/editor/RelatedToField.tsx +2 -0
- package/src/modules/customers/components/calendar/editor/ScheduleSection.tsx +7 -0
- package/src/modules/customers/i18n/de.json +11 -7
- package/src/modules/customers/i18n/en.json +6 -2
- package/src/modules/customers/i18n/es.json +11 -7
- package/src/modules/customers/i18n/pl.json +11 -7
- package/src/modules/customers/lib/calendar/labels.ts +42 -0
- package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +39 -11
- package/src/modules/data_sync/api/mappings/[id]/route.ts +63 -0
- package/src/modules/data_sync/api/mappings/route.ts +48 -0
- package/src/modules/data_sync/api/run.ts +33 -0
- package/src/modules/data_sync/api/runs/[id]/cancel.ts +34 -0
- package/src/modules/data_sync/api/runs/[id]/retry.ts +33 -0
- package/src/modules/data_sync/api/schedules/[id]/route.ts +64 -2
- package/src/modules/data_sync/api/schedules/route.ts +36 -4
- package/src/modules/directory/api/organization-branding/route.ts +61 -0
- package/src/modules/directory/backend/directory/branding/page.tsx +16 -10
- package/src/modules/directory/backend/directory/organizations/page.tsx +35 -8
- package/src/modules/directory/backend/directory/tenants/page.tsx +37 -13
- package/src/modules/entities/api/definitions.batch.ts +17 -0
- package/src/modules/entities/api/definitions.mutation-guard.ts +80 -0
- package/src/modules/entities/api/definitions.restore.ts +17 -0
- package/src/modules/entities/api/definitions.ts +30 -0
- package/src/modules/entities/api/entities.ts +35 -3
- package/src/modules/entities/api/records.ts +20 -0
- package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +33 -10
- package/src/modules/feature_toggles/api/overrides/route.ts +44 -1
- package/src/modules/inbox_ops/api/emails/[id]/route.ts +32 -0
- package/src/modules/inbox_ops/api/proposals/[id]/accept-all/route.ts +32 -0
- package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +33 -0
- package/src/modules/inbox_ops/api/proposals/[id]/categorize/route.ts +33 -0
- package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +80 -42
- package/src/modules/notifications/frontend/NotificationSettingsPageClient.tsx +21 -4
- package/src/modules/payment_gateways/api/cancel/route.ts +39 -0
- package/src/modules/payment_gateways/api/capture/route.ts +39 -0
- package/src/modules/payment_gateways/api/guards.ts +59 -0
- package/src/modules/payment_gateways/api/refund/route.ts +39 -0
- package/src/modules/payment_gateways/api/sessions/route.ts +40 -0
- package/src/modules/planner/api/availability-date-specific.ts +10 -0
- package/src/modules/planner/backend/planner/availability-rulesets/page.tsx +26 -5
- package/src/modules/planner/commands/availability-date-specific.ts +24 -0
- package/src/modules/planner/components/AvailabilityRulesEditor.tsx +122 -41
- package/src/modules/query_index/api/purge.ts +37 -3
- package/src/modules/query_index/api/reindex.ts +43 -3
- package/src/modules/query_index/components/QueryIndexesTable.tsx +66 -24
- package/src/modules/shipping_carriers/api/shipments/route.ts +31 -0
- package/src/modules/shipping_carriers/api/tracking/refresh/route.ts +53 -0
- package/src/modules/shipping_carriers/data/validators.ts +5 -0
- package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +29 -8
- package/src/modules/shipping_carriers/lib/shipping-service.ts +43 -7
- package/src/modules/shipping_carriers/workers/status-poller.ts +1 -1
- package/src/modules/translations/components/TranslationManager.tsx +65 -21
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/directory/backend/directory/branding/page.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { ImagePlus, Loader2, RotateCcw, Save } from 'lucide-react'\nimport { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'\nimport { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype BrandingPayload = {\n organizationId: string\n organizationName: string\n tenantId: string\n logoUrl: string | null\n}\n\ntype UploadPayload = {\n ok: true\n item: {\n id: string\n url: string\n thumbnailUrl?: string\n }\n}\n\nconst BRANDING_API = '/api/directory/organization-branding'\nconst BRANDING_ENTITY_ID = 'directory.organization'\n\nexport default function OrganizationBrandingPage() {\n const t = useT()\n const queryClient = useQueryClient()\n const [logoUrl, setLogoUrl] = React.useState('')\n const [selectedFile, setSelectedFile] = React.useState<File | null>(null)\n const [filePreviewUrl, setFilePreviewUrl] = React.useState<string | null>(null)\n const [saving, setSaving] = React.useState(false)\n const fileInputRef = React.useRef<HTMLInputElement | null>(null)\n const { runMutation } = useGuardedMutation({\n contextId: 'directory.organization-branding',\n blockedMessage: t('directory.branding.errors.blocked', 'Branding save was blocked.'),\n })\n\n const { data, isLoading, error } = useQuery<BrandingPayload>({\n queryKey: ['directory-organization-branding'],\n queryFn: () => readApiResultOrThrow<BrandingPayload>(\n BRANDING_API,\n undefined,\n { errorMessage: t('directory.branding.errors.load', 'Failed to load organization branding') },\n ),\n })\n\n React.useEffect(() => {\n setLogoUrl(data?.logoUrl ?? '')\n setSelectedFile(null)\n }, [data?.logoUrl])\n\n React.useEffect(() => {\n if (!selectedFile || typeof URL === 'undefined') {\n setFilePreviewUrl(null)\n return\n }\n const nextPreviewUrl = URL.createObjectURL(selectedFile)\n setFilePreviewUrl(nextPreviewUrl)\n return () => URL.revokeObjectURL(nextPreviewUrl)\n }, [selectedFile])\n\n const currentPreviewUrl = filePreviewUrl ?? logoUrl\n\n const uploadLogo = React.useCallback(async (organizationId: string): Promise<string | null> => {\n if (!selectedFile) return null\n const form = new FormData()\n form.set('entityId', BRANDING_ENTITY_ID)\n form.set('recordId', organizationId)\n form.set('file', selectedFile)\n form.set('tags', JSON.stringify(['organization-logo']))\n\n const upload = await readApiResultOrThrow<UploadPayload>(\n '/api/attachments',\n {\n method: 'POST',\n body: form,\n },\n { errorMessage: t('directory.branding.errors.upload', 'Failed to upload logo') },\n )\n return upload?.item.thumbnailUrl ?? upload?.item.url ?? null\n }, [selectedFile, t])\n\n const saveBranding = React.useCallback(async (nextLogoUrl?: string, options?: { skipUpload?: boolean }) => {\n if (!data) return\n const shouldUpload = Boolean(selectedFile && !options?.skipUpload)\n setSaving(true)\n try {\n await runMutation({\n operation: async () => {\n const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null\n const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim()\n // optimistic-lock-exempt: selected organization branding uses a scoped command endpoint without an exposed updatedAt token.\n const response = await apiCallOrThrow<BrandingPayload>(\n BRANDING_API,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ logoUrl: resolvedLogoUrl || null }),\n },\n { errorMessage: t('directory.branding.errors.save', 'Failed to update organization branding') },\n )\n return response.result\n },\n context: {\n entityId: BRANDING_ENTITY_ID,\n recordId: data.organizationId,\n operation: 'update-branding',\n },\n mutationPayload: {\n organizationId: data.organizationId,\n logoUrl: (nextLogoUrl ?? logoUrl.trim()) || null,\n hasUpload: shouldUpload,\n },\n })\n await queryClient.invalidateQueries({ queryKey: ['directory-organization-branding'] })\n window.dispatchEvent(new Event('om:refresh-sidebar'))\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n flash(t('directory.branding.flash.saved', 'Organization branding updated'), 'success')\n } catch (err: unknown) {\n const fallback = t('directory.branding.errors.save', 'Failed to update organization branding')\n const message = err instanceof Error ? err.message : fallback\n flash(message, 'error')\n } finally {\n setSaving(false)\n }\n }, [data, logoUrl, queryClient, runMutation, selectedFile, t, uploadLogo])\n\n const handleSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {\n event.preventDefault()\n void saveBranding()\n }, [saveBranding])\n\n if (isLoading) {\n return <LoadingMessage label={t('directory.branding.loading', 'Loading organization branding...')} />\n }\n\n if (error || !data) {\n return (\n <ErrorMessage\n label={t('directory.branding.errors.load', 'Failed to load organization branding')}\n description={error instanceof Error ? error.message : undefined}\n />\n )\n }\n\n return (\n <Page>\n <PageHeader\n title={t('directory.branding.title', 'Organization branding')}\n description={t(\n 'directory.branding.description',\n 'Set the logo used in the backend sidebar for the currently selected organization.',\n )}\n />\n <PageBody>\n <form className=\"space-y-5\" onSubmit={handleSubmit}>\n <div className=\"grid gap-5 lg:grid-cols-[260px_1fr]\">\n <div className=\"space-y-3\">\n <div className=\"flex aspect-square w-full max-w-[220px] items-center justify-center overflow-hidden rounded-lg border bg-muted/30\">\n {currentPreviewUrl ? (\n <img\n src={currentPreviewUrl}\n alt={t('directory.branding.previewAlt', '{{name}} logo preview', { name: data.organizationName })}\n className=\"h-full w-full object-contain\"\n />\n ) : (\n <ImagePlus className=\"size-10 text-muted-foreground\" aria-hidden />\n )}\n </div>\n <p className=\"text-sm font-medium text-foreground\">{data.organizationName}</p>\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.currentScope', 'Current organization')}\n </p>\n </div>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-file\" className=\"text-sm font-medium\">\n {t('directory.branding.file.label', 'Upload logo')}\n </label>\n <Input\n ref={fileInputRef}\n id=\"organization-logo-file\"\n type=\"file\"\n accept=\"image/png,image/jpeg,image/webp,image/svg+xml\"\n onChange={(event) => {\n const file = event.currentTarget.files?.[0]\n if (!file) return\n setSelectedFile(file)\n }}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.file.hint', 'PNG, JPG, WebP, or SVG works best. Uploaded files are stored as organization attachments.')}\n </p>\n </div>\n\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-url\" className=\"text-sm font-medium\">\n {t('directory.branding.url.label', 'Logo URL')}\n </label>\n <Input\n id=\"organization-logo-url\"\n value={logoUrl}\n onChange={(event) => setLogoUrl(event.currentTarget.value)}\n placeholder={t('directory.branding.url.placeholder', 'https://example.com/logo.svg')}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.url.hint', 'Use an external image URL or leave empty to fall back to the default Open Mercato logo.')}\n </p>\n </div>\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button type=\"submit\" disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 size-4 animate-spin\" aria-hidden /> : <Save className=\"mr-2 size-4\" aria-hidden />}\n {t('directory.branding.actions.save', 'Save branding')}\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n disabled={saving}\n onClick={() => {\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n setLogoUrl('')\n void saveBranding('', { skipUpload: true })\n }}\n >\n <RotateCcw className=\"mr-2 size-4\" aria-hidden />\n {t('directory.branding.actions.reset', 'Use default logo')}\n </Button>\n </div>\n </div>\n </div>\n </form>\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { ImagePlus, Loader2, RotateCcw, Save } from 'lucide-react'\nimport { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page'\nimport { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype BrandingPayload = {\n organizationId: string\n organizationName: string\n tenantId: string\n logoUrl: string | null\n updatedAt: string | null\n}\n\ntype UploadPayload = {\n ok: true\n item: {\n id: string\n url: string\n thumbnailUrl?: string\n }\n}\n\nconst BRANDING_API = '/api/directory/organization-branding'\nconst BRANDING_ENTITY_ID = 'directory.organization'\n\nexport default function OrganizationBrandingPage() {\n const t = useT()\n const queryClient = useQueryClient()\n const [logoUrl, setLogoUrl] = React.useState('')\n const [selectedFile, setSelectedFile] = React.useState<File | null>(null)\n const [filePreviewUrl, setFilePreviewUrl] = React.useState<string | null>(null)\n const [saving, setSaving] = React.useState(false)\n const fileInputRef = React.useRef<HTMLInputElement | null>(null)\n const { runMutation } = useGuardedMutation({\n contextId: 'directory.organization-branding',\n blockedMessage: t('directory.branding.errors.blocked', 'Branding save was blocked.'),\n })\n\n const { data, isLoading, error } = useQuery<BrandingPayload>({\n queryKey: ['directory-organization-branding'],\n queryFn: () => readApiResultOrThrow<BrandingPayload>(\n BRANDING_API,\n undefined,\n { errorMessage: t('directory.branding.errors.load', 'Failed to load organization branding') },\n ),\n })\n\n React.useEffect(() => {\n setLogoUrl(data?.logoUrl ?? '')\n setSelectedFile(null)\n }, [data?.logoUrl])\n\n React.useEffect(() => {\n if (!selectedFile || typeof URL === 'undefined') {\n setFilePreviewUrl(null)\n return\n }\n const nextPreviewUrl = URL.createObjectURL(selectedFile)\n setFilePreviewUrl(nextPreviewUrl)\n return () => URL.revokeObjectURL(nextPreviewUrl)\n }, [selectedFile])\n\n const currentPreviewUrl = filePreviewUrl ?? logoUrl\n\n const uploadLogo = React.useCallback(async (organizationId: string): Promise<string | null> => {\n if (!selectedFile) return null\n const form = new FormData()\n form.set('entityId', BRANDING_ENTITY_ID)\n form.set('recordId', organizationId)\n form.set('file', selectedFile)\n form.set('tags', JSON.stringify(['organization-logo']))\n\n const upload = await readApiResultOrThrow<UploadPayload>(\n '/api/attachments',\n {\n method: 'POST',\n body: form,\n },\n { errorMessage: t('directory.branding.errors.upload', 'Failed to upload logo') },\n )\n return upload?.item.thumbnailUrl ?? upload?.item.url ?? null\n }, [selectedFile, t])\n\n const saveBranding = React.useCallback(async (nextLogoUrl?: string, options?: { skipUpload?: boolean }) => {\n if (!data) return\n const shouldUpload = Boolean(selectedFile && !options?.skipUpload)\n setSaving(true)\n try {\n await runMutation({\n operation: async () => {\n const uploadedLogoUrl = shouldUpload ? await uploadLogo(data.organizationId) : null\n const resolvedLogoUrl = uploadedLogoUrl ?? nextLogoUrl ?? logoUrl.trim()\n const response = await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(data.updatedAt),\n () => apiCallOrThrow<BrandingPayload>(\n BRANDING_API,\n {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ logoUrl: resolvedLogoUrl || null }),\n },\n { errorMessage: t('directory.branding.errors.save', 'Failed to update organization branding') },\n ),\n )\n return response.result\n },\n context: {\n entityId: BRANDING_ENTITY_ID,\n recordId: data.organizationId,\n operation: 'update-branding',\n },\n mutationPayload: {\n organizationId: data.organizationId,\n logoUrl: (nextLogoUrl ?? logoUrl.trim()) || null,\n hasUpload: shouldUpload,\n },\n })\n await queryClient.invalidateQueries({ queryKey: ['directory-organization-branding'] })\n window.dispatchEvent(new Event('om:refresh-sidebar'))\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n flash(t('directory.branding.flash.saved', 'Organization branding updated'), 'success')\n } catch (err: unknown) {\n if (surfaceRecordConflict(err, t)) return\n const fallback = t('directory.branding.errors.save', 'Failed to update organization branding')\n const message = err instanceof Error ? err.message : fallback\n flash(message, 'error')\n } finally {\n setSaving(false)\n }\n }, [data, logoUrl, queryClient, runMutation, selectedFile, t, uploadLogo])\n\n const handleSubmit = React.useCallback((event: React.FormEvent<HTMLFormElement>) => {\n event.preventDefault()\n void saveBranding()\n }, [saveBranding])\n\n if (isLoading) {\n return <LoadingMessage label={t('directory.branding.loading', 'Loading organization branding...')} />\n }\n\n if (error || !data) {\n return (\n <ErrorMessage\n label={t('directory.branding.errors.load', 'Failed to load organization branding')}\n description={error instanceof Error ? error.message : undefined}\n />\n )\n }\n\n return (\n <Page>\n <PageHeader\n title={t('directory.branding.title', 'Organization branding')}\n description={t(\n 'directory.branding.description',\n 'Set the logo used in the backend sidebar for the currently selected organization.',\n )}\n />\n <PageBody>\n <form className=\"space-y-5\" onSubmit={handleSubmit}>\n <div className=\"grid gap-5 lg:grid-cols-[260px_1fr]\">\n <div className=\"space-y-3\">\n <div className=\"flex aspect-square w-full max-w-[220px] items-center justify-center overflow-hidden rounded-lg border bg-muted/30\">\n {currentPreviewUrl ? (\n <img\n src={currentPreviewUrl}\n alt={t('directory.branding.previewAlt', '{{name}} logo preview', { name: data.organizationName })}\n className=\"h-full w-full object-contain\"\n />\n ) : (\n <ImagePlus className=\"size-10 text-muted-foreground\" aria-hidden />\n )}\n </div>\n <p className=\"text-sm font-medium text-foreground\">{data.organizationName}</p>\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.currentScope', 'Current organization')}\n </p>\n </div>\n\n <div className=\"space-y-4\">\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-file\" className=\"text-sm font-medium\">\n {t('directory.branding.file.label', 'Upload logo')}\n </label>\n <Input\n ref={fileInputRef}\n id=\"organization-logo-file\"\n type=\"file\"\n accept=\"image/png,image/jpeg,image/webp,image/svg+xml\"\n onChange={(event) => {\n const file = event.currentTarget.files?.[0]\n if (!file) return\n setSelectedFile(file)\n }}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.file.hint', 'PNG, JPG, WebP, or SVG works best. Uploaded files are stored as organization attachments.')}\n </p>\n </div>\n\n <div className=\"space-y-2\">\n <label htmlFor=\"organization-logo-url\" className=\"text-sm font-medium\">\n {t('directory.branding.url.label', 'Logo URL')}\n </label>\n <Input\n id=\"organization-logo-url\"\n value={logoUrl}\n onChange={(event) => setLogoUrl(event.currentTarget.value)}\n placeholder={t('directory.branding.url.placeholder', 'https://example.com/logo.svg')}\n />\n <p className=\"text-xs text-muted-foreground\">\n {t('directory.branding.url.hint', 'Use an external image URL or leave empty to fall back to the default Open Mercato logo.')}\n </p>\n </div>\n\n <div className=\"flex flex-wrap items-center gap-2\">\n <Button type=\"submit\" disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 size-4 animate-spin\" aria-hidden /> : <Save className=\"mr-2 size-4\" aria-hidden />}\n {t('directory.branding.actions.save', 'Save branding')}\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n disabled={saving}\n onClick={() => {\n setSelectedFile(null)\n if (fileInputRef.current) fileInputRef.current.value = ''\n setLogoUrl('')\n void saveBranding('', { skipUpload: true })\n }}\n >\n <RotateCcw className=\"mr-2 size-4\" aria-hidden />\n {t('directory.branding.actions.reset', 'Use default logo')}\n </Button>\n </div>\n </div>\n </div>\n </form>\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -12,6 +12,8 @@ import { Button } from "@open-mercato/ui/primitives/button";
|
|
|
12
12
|
import { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from "@open-mercato/ui/backend/utils/apiCall";
|
|
13
13
|
import { buildOptimisticLockHeader } from "@open-mercato/ui/backend/utils/optimisticLock";
|
|
14
14
|
import { flash } from "@open-mercato/ui/backend/FlashMessages";
|
|
15
|
+
import { useGuardedMutation } from "@open-mercato/ui/backend/injection/useGuardedMutation";
|
|
16
|
+
import { surfaceRecordConflict } from "@open-mercato/ui/backend/conflicts";
|
|
15
17
|
import { useConfirmDialog } from "@open-mercato/ui/backend/confirm-dialog";
|
|
16
18
|
import { useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
|
|
17
19
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
@@ -138,6 +140,11 @@ function DirectoryOrganizationsPage() {
|
|
|
138
140
|
}, [isSuperAdmin, t]);
|
|
139
141
|
const total = data?.total ?? 0;
|
|
140
142
|
const totalPages = data?.totalPages ?? 0;
|
|
143
|
+
const deleteMutationContextId = "directory-organizations-list:single-delete";
|
|
144
|
+
const { runMutation: runDeleteMutation, retryLastMutation: retryDeleteMutation } = useGuardedMutation({
|
|
145
|
+
contextId: deleteMutationContextId,
|
|
146
|
+
blockedMessage: t("ui.forms.flash.saveBlocked", "Save blocked by validation")
|
|
147
|
+
});
|
|
141
148
|
const handleDelete = React.useCallback(async (org) => {
|
|
142
149
|
const confirmLabel = t("directory.organizations.list.confirmDelete", 'Archive organization "{{name}}"?', { name: org.name });
|
|
143
150
|
const confirmed = await confirm({
|
|
@@ -147,22 +154,34 @@ function DirectoryOrganizationsPage() {
|
|
|
147
154
|
});
|
|
148
155
|
if (!confirmed) return;
|
|
149
156
|
try {
|
|
150
|
-
await
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
await runDeleteMutation({
|
|
158
|
+
operation: () => withScopedApiRequestHeaders(
|
|
159
|
+
buildOptimisticLockHeader(org.updatedAt),
|
|
160
|
+
() => apiCallOrThrow(
|
|
161
|
+
`/api/directory/organizations?id=${encodeURIComponent(org.id)}`,
|
|
162
|
+
{ method: "DELETE" },
|
|
163
|
+
{ errorMessage: t("directory.organizations.list.error.delete", "Failed to delete organization") }
|
|
164
|
+
)
|
|
165
|
+
),
|
|
166
|
+
context: {
|
|
167
|
+
formId: deleteMutationContextId,
|
|
168
|
+
resourceKind: "directory.organization",
|
|
169
|
+
resourceId: org.id,
|
|
170
|
+
retryLastMutation: retryDeleteMutation
|
|
171
|
+
}
|
|
172
|
+
});
|
|
158
173
|
await queryClient.invalidateQueries({ queryKey: ["directory-organizations"] });
|
|
159
174
|
flash(t("directory.organizations.flash.deleted", "Organization deleted"), "success");
|
|
160
175
|
} catch (err) {
|
|
176
|
+
if (surfaceRecordConflict(err, t)) {
|
|
177
|
+
await queryClient.invalidateQueries({ queryKey: ["directory-organizations"] });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
161
180
|
const fallback = t("directory.organizations.list.error.delete", "Failed to delete organization");
|
|
162
181
|
const message = err instanceof Error ? err.message : fallback;
|
|
163
182
|
flash(message, "error");
|
|
164
183
|
}
|
|
165
|
-
}, [confirm, queryClient, t]);
|
|
184
|
+
}, [confirm, deleteMutationContextId, queryClient, retryDeleteMutation, runDeleteMutation, t]);
|
|
166
185
|
return /* @__PURE__ */ jsxs(Page, { children: [
|
|
167
186
|
/* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(
|
|
168
187
|
DataTable,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/directory/backend/directory/organizations/page.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport type { ColumnDef } from '@tanstack/react-table'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport type { FilterValues } from '@open-mercato/ui/backend/FilterBar'\nimport { BooleanIcon } from '@open-mercato/ui/backend/ValueIcons'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype OrganizationRow = {\n id: string\n name: string\n tenantId: string\n tenantName?: string | null\n parentId: string | null\n parentName: string | null\n depth: number\n rootId: string\n treePath: string\n pathLabel: string\n ancestorIds: string[]\n childIds: string[]\n descendantIds: string[]\n childrenCount: number\n descendantsCount: number\n isActive: boolean\n updatedAt?: string | null\n}\n\ntype OrganizationsResponse = {\n items: OrganizationRow[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n isSuperAdmin?: boolean\n}\n\nconst TREE_BASE_INDENT = 18\nconst TREE_STEP_INDENT = 14\n\nfunction formatTreeLabel(name: string, depth: number): string {\n if (depth <= 0) return name\n return `${'\\u00A0'.repeat(Math.max(0, (depth - 1) * 2))}\u21B3 ${name}`\n}\n\nfunction computeIndent(depth: number): number {\n if (depth <= 0) return 0\n return TREE_BASE_INDENT + (depth - 1) * TREE_STEP_INDENT\n}\n\nexport default function DirectoryOrganizationsPage() {\n const queryClient = useQueryClient()\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n const [page, setPage] = React.useState(1)\n const [status, setStatus] = React.useState<string>('all')\n const [search, setSearch] = React.useState('')\n const [canManage, setCanManage] = React.useState(false)\n const scopeVersion = useOrganizationScopeVersion()\n const t = useT()\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n try {\n const call = await apiCall<{ granted?: string[]; ok?: boolean }>('/api/auth/feature-check', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ features: ['directory.organizations.manage'] }),\n })\n if (!cancelled) {\n const granted = Array.isArray(call.result?.granted) ? call.result?.granted : []\n setCanManage(call.result?.ok === true || granted.includes('directory.organizations.manage'))\n }\n } catch {\n if (!cancelled) setCanManage(false)\n }\n }\n load()\n return () => { cancelled = true }\n }, [])\n\n const queryParams = React.useMemo(() => {\n const params = new URLSearchParams()\n params.set('view', 'manage')\n params.set('page', String(page))\n params.set('pageSize', '50')\n params.set('status', status)\n if (status !== 'active') params.set('includeInactive', 'true')\n if (search) params.set('search', search)\n return params.toString()\n }, [page, status, search])\n\n const { data, isLoading } = useQuery<OrganizationsResponse>({\n queryKey: ['directory-organizations', queryParams, scopeVersion],\n queryFn: async () => {\n return readApiResultOrThrow<OrganizationsResponse>(\n `/api/directory/organizations?${queryParams}`,\n undefined,\n { errorMessage: t('directory.organizations.list.error.load', 'Failed to load organizations') },\n )\n },\n })\n\n const rows = data?.items ?? []\n const isSuperAdmin = data?.isSuperAdmin ?? false\n const columns = React.useMemo<ColumnDef<OrganizationRow>[]>(() => {\n const base: ColumnDef<OrganizationRow>[] = [\n {\n accessorKey: 'name',\n header: t('directory.organizations.list.columns.organization', 'Organization'),\n cell: ({ row }) => {\n const depth = row.original.depth ?? 0\n return (\n <div className=\"flex items-center text-sm font-medium leading-none text-foreground\">\n <span\n style={{ marginLeft: computeIndent(depth), whiteSpace: 'pre' }}\n >\n {formatTreeLabel(row.original.name, depth)}\n </span>\n </div>\n )\n },\n meta: { priority: 1 },\n },\n {\n accessorKey: 'pathLabel',\n header: t('directory.organizations.list.columns.path', 'Path'),\n meta: { priority: 3 },\n cell: ({ getValue }) => {\n const value = getValue<string>()\n return <span className=\"text-xs text-muted-foreground\">{value}</span>\n },\n },\n {\n accessorKey: 'parentName',\n header: t('directory.organizations.list.columns.parent', 'Parent'),\n meta: { priority: 4 },\n cell: ({ getValue }) => getValue<string>() || t('directory.organizations.common.none', '\u2014'),\n },\n {\n accessorKey: 'childrenCount',\n header: t('directory.organizations.list.columns.children', 'Children'),\n meta: { priority: 5 },\n },\n {\n accessorKey: 'isActive',\n header: t('directory.organizations.list.columns.active', 'Active'),\n enableSorting: false,\n meta: { priority: 2 },\n cell: ({ getValue }) => <BooleanIcon value={Boolean(getValue())} />, \n },\n ]\n if (isSuperAdmin) {\n base.splice(1, 0, {\n accessorKey: 'tenantName',\n header: t('directory.organizations.list.columns.tenant', 'Tenant'),\n meta: { priority: 2 },\n cell: ({ row }) => {\n const value = row.original.tenantName ?? row.original.tenantId\n return <span className=\"text-xs text-muted-foreground\">{value}</span>\n },\n })\n }\n return base\n }, [isSuperAdmin, t])\n const total = data?.total ?? 0\n const totalPages = data?.totalPages ?? 0\n\n const handleDelete = React.useCallback(async (org: OrganizationRow) => {\n const confirmLabel = t('directory.organizations.list.confirmDelete', 'Archive organization \"{{name}}\"?', { name: org.name })\n const confirmed = await confirm({\n title: t('directory.organizations.list.actions.delete', 'Delete'),\n text: confirmLabel,\n variant: 'destructive',\n })\n if (!confirmed) return\n\n try {\n await withScopedApiRequestHeaders(\n buildOptimisticLockHeader(org.updatedAt),\n () => apiCallOrThrow(\n `/api/directory/organizations?id=${encodeURIComponent(org.id)}`,\n { method: 'DELETE' },\n { errorMessage: t('directory.organizations.list.error.delete', 'Failed to delete organization') },\n ),\n )\n await queryClient.invalidateQueries({ queryKey: ['directory-organizations'] })\n flash(t('directory.organizations.flash.deleted', 'Organization deleted'), 'success')\n } catch (err: unknown) {\n const fallback = t('directory.organizations.list.error.delete', 'Failed to delete organization')\n const message = err instanceof Error ? err.message : fallback\n flash(message, 'error')\n }\n }, [confirm, queryClient, t])\n\n return (\n <Page>\n <PageBody>\n <DataTable\n title={t('directory.organizations.list.title', 'Organizations')}\n actions={canManage ? (\n <Button asChild>\n <Link href=\"/backend/directory/organizations/create\">\n {t('directory.organizations.list.actions.create', 'Create')}\n </Link>\n </Button>\n ) : undefined}\n columns={columns}\n data={rows}\n searchValue={search}\n searchPlaceholder={t('directory.organizations.list.searchPlaceholder', 'Search organizations')}\n onSearchChange={(value) => { setSearch(value); setPage(1) }}\n filters={[\n {\n id: 'status',\n label: t('directory.organizations.list.filters.status', 'Status'),\n type: 'select',\n options: [\n { value: 'all', label: t('directory.organizations.list.filters.all', 'All') },\n { value: 'active', label: t('directory.organizations.list.filters.active', 'Active') },\n { value: 'inactive', label: t('directory.organizations.list.filters.inactive', 'Inactive') },\n ],\n },\n ]}\n filterValues={status === 'all' ? {} : { status }}\n onFiltersApply={(vals: FilterValues) => {\n const nextStatus = (vals.status as string) || 'all'\n setStatus(nextStatus)\n setPage(1)\n }}\n onFiltersClear={() => {\n setStatus('all')\n setPage(1)\n }}\n sortable={false}\n perspective={{ tableId: 'directory.organizations.list' }}\n rowActions={(row) => (\n canManage ? (\n <RowActions\n items={[\n { id: 'edit', label: t('directory.organizations.list.actions.edit', 'Edit'), href: `/backend/directory/organizations/${row.id}/edit` },\n { id: 'delete', label: t('directory.organizations.list.actions.delete', 'Delete'), destructive: true, onSelect: () => handleDelete(row) },\n ]}\n />\n ) : null\n )}\n emptyState={(\n <ListEmptyState\n entityName={t('directory.organizations.list.title', 'Organizations')}\n createHref=\"/backend/directory/organizations/create\"\n createLabel={t('directory.organizations.list.actions.create', 'Create')}\n />\n )}\n pagination={{ page, pageSize: 50, total, totalPages, onPageChange: setPage }}\n isLoading={isLoading}\n />\n </PageBody>\n {ConfirmDialogElement}\n </Page>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport type { ColumnDef } from '@tanstack/react-table'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport type { FilterValues } from '@open-mercato/ui/backend/FilterBar'\nimport { BooleanIcon } from '@open-mercato/ui/backend/ValueIcons'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype OrganizationRow = {\n id: string\n name: string\n tenantId: string\n tenantName?: string | null\n parentId: string | null\n parentName: string | null\n depth: number\n rootId: string\n treePath: string\n pathLabel: string\n ancestorIds: string[]\n childIds: string[]\n descendantIds: string[]\n childrenCount: number\n descendantsCount: number\n isActive: boolean\n updatedAt?: string | null\n}\n\ntype OrganizationsResponse = {\n items: OrganizationRow[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n isSuperAdmin?: boolean\n}\n\nconst TREE_BASE_INDENT = 18\nconst TREE_STEP_INDENT = 14\n\nfunction formatTreeLabel(name: string, depth: number): string {\n if (depth <= 0) return name\n return `${'\\u00A0'.repeat(Math.max(0, (depth - 1) * 2))}\u21B3 ${name}`\n}\n\nfunction computeIndent(depth: number): number {\n if (depth <= 0) return 0\n return TREE_BASE_INDENT + (depth - 1) * TREE_STEP_INDENT\n}\n\nexport default function DirectoryOrganizationsPage() {\n const queryClient = useQueryClient()\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n const [page, setPage] = React.useState(1)\n const [status, setStatus] = React.useState<string>('all')\n const [search, setSearch] = React.useState('')\n const [canManage, setCanManage] = React.useState(false)\n const scopeVersion = useOrganizationScopeVersion()\n const t = useT()\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n try {\n const call = await apiCall<{ granted?: string[]; ok?: boolean }>('/api/auth/feature-check', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ features: ['directory.organizations.manage'] }),\n })\n if (!cancelled) {\n const granted = Array.isArray(call.result?.granted) ? call.result?.granted : []\n setCanManage(call.result?.ok === true || granted.includes('directory.organizations.manage'))\n }\n } catch {\n if (!cancelled) setCanManage(false)\n }\n }\n load()\n return () => { cancelled = true }\n }, [])\n\n const queryParams = React.useMemo(() => {\n const params = new URLSearchParams()\n params.set('view', 'manage')\n params.set('page', String(page))\n params.set('pageSize', '50')\n params.set('status', status)\n if (status !== 'active') params.set('includeInactive', 'true')\n if (search) params.set('search', search)\n return params.toString()\n }, [page, status, search])\n\n const { data, isLoading } = useQuery<OrganizationsResponse>({\n queryKey: ['directory-organizations', queryParams, scopeVersion],\n queryFn: async () => {\n return readApiResultOrThrow<OrganizationsResponse>(\n `/api/directory/organizations?${queryParams}`,\n undefined,\n { errorMessage: t('directory.organizations.list.error.load', 'Failed to load organizations') },\n )\n },\n })\n\n const rows = data?.items ?? []\n const isSuperAdmin = data?.isSuperAdmin ?? false\n const columns = React.useMemo<ColumnDef<OrganizationRow>[]>(() => {\n const base: ColumnDef<OrganizationRow>[] = [\n {\n accessorKey: 'name',\n header: t('directory.organizations.list.columns.organization', 'Organization'),\n cell: ({ row }) => {\n const depth = row.original.depth ?? 0\n return (\n <div className=\"flex items-center text-sm font-medium leading-none text-foreground\">\n <span\n style={{ marginLeft: computeIndent(depth), whiteSpace: 'pre' }}\n >\n {formatTreeLabel(row.original.name, depth)}\n </span>\n </div>\n )\n },\n meta: { priority: 1 },\n },\n {\n accessorKey: 'pathLabel',\n header: t('directory.organizations.list.columns.path', 'Path'),\n meta: { priority: 3 },\n cell: ({ getValue }) => {\n const value = getValue<string>()\n return <span className=\"text-xs text-muted-foreground\">{value}</span>\n },\n },\n {\n accessorKey: 'parentName',\n header: t('directory.organizations.list.columns.parent', 'Parent'),\n meta: { priority: 4 },\n cell: ({ getValue }) => getValue<string>() || t('directory.organizations.common.none', '\u2014'),\n },\n {\n accessorKey: 'childrenCount',\n header: t('directory.organizations.list.columns.children', 'Children'),\n meta: { priority: 5 },\n },\n {\n accessorKey: 'isActive',\n header: t('directory.organizations.list.columns.active', 'Active'),\n enableSorting: false,\n meta: { priority: 2 },\n cell: ({ getValue }) => <BooleanIcon value={Boolean(getValue())} />, \n },\n ]\n if (isSuperAdmin) {\n base.splice(1, 0, {\n accessorKey: 'tenantName',\n header: t('directory.organizations.list.columns.tenant', 'Tenant'),\n meta: { priority: 2 },\n cell: ({ row }) => {\n const value = row.original.tenantName ?? row.original.tenantId\n return <span className=\"text-xs text-muted-foreground\">{value}</span>\n },\n })\n }\n return base\n }, [isSuperAdmin, t])\n const total = data?.total ?? 0\n const totalPages = data?.totalPages ?? 0\n\n const deleteMutationContextId = 'directory-organizations-list:single-delete'\n const { runMutation: runDeleteMutation, retryLastMutation: retryDeleteMutation } = useGuardedMutation<{\n formId: string\n resourceKind: string\n resourceId: string\n retryLastMutation: () => Promise<boolean>\n }>({\n contextId: deleteMutationContextId,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n const handleDelete = React.useCallback(async (org: OrganizationRow) => {\n const confirmLabel = t('directory.organizations.list.confirmDelete', 'Archive organization \"{{name}}\"?', { name: org.name })\n const confirmed = await confirm({\n title: t('directory.organizations.list.actions.delete', 'Delete'),\n text: confirmLabel,\n variant: 'destructive',\n })\n if (!confirmed) return\n\n try {\n await runDeleteMutation({\n operation: () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(org.updatedAt),\n () => apiCallOrThrow(\n `/api/directory/organizations?id=${encodeURIComponent(org.id)}`,\n { method: 'DELETE' },\n { errorMessage: t('directory.organizations.list.error.delete', 'Failed to delete organization') },\n ),\n ),\n context: {\n formId: deleteMutationContextId,\n resourceKind: 'directory.organization',\n resourceId: org.id,\n retryLastMutation: retryDeleteMutation,\n },\n })\n await queryClient.invalidateQueries({ queryKey: ['directory-organizations'] })\n flash(t('directory.organizations.flash.deleted', 'Organization deleted'), 'success')\n } catch (err: unknown) {\n // A stale delete surfaces the unified conflict bar (via the guarded\n // mutation) \u2014 skip the generic error flash to avoid a double message.\n if (surfaceRecordConflict(err, t)) {\n await queryClient.invalidateQueries({ queryKey: ['directory-organizations'] })\n return\n }\n const fallback = t('directory.organizations.list.error.delete', 'Failed to delete organization')\n const message = err instanceof Error ? err.message : fallback\n flash(message, 'error')\n }\n }, [confirm, deleteMutationContextId, queryClient, retryDeleteMutation, runDeleteMutation, t])\n\n return (\n <Page>\n <PageBody>\n <DataTable\n title={t('directory.organizations.list.title', 'Organizations')}\n actions={canManage ? (\n <Button asChild>\n <Link href=\"/backend/directory/organizations/create\">\n {t('directory.organizations.list.actions.create', 'Create')}\n </Link>\n </Button>\n ) : undefined}\n columns={columns}\n data={rows}\n searchValue={search}\n searchPlaceholder={t('directory.organizations.list.searchPlaceholder', 'Search organizations')}\n onSearchChange={(value) => { setSearch(value); setPage(1) }}\n filters={[\n {\n id: 'status',\n label: t('directory.organizations.list.filters.status', 'Status'),\n type: 'select',\n options: [\n { value: 'all', label: t('directory.organizations.list.filters.all', 'All') },\n { value: 'active', label: t('directory.organizations.list.filters.active', 'Active') },\n { value: 'inactive', label: t('directory.organizations.list.filters.inactive', 'Inactive') },\n ],\n },\n ]}\n filterValues={status === 'all' ? {} : { status }}\n onFiltersApply={(vals: FilterValues) => {\n const nextStatus = (vals.status as string) || 'all'\n setStatus(nextStatus)\n setPage(1)\n }}\n onFiltersClear={() => {\n setStatus('all')\n setPage(1)\n }}\n sortable={false}\n perspective={{ tableId: 'directory.organizations.list' }}\n rowActions={(row) => (\n canManage ? (\n <RowActions\n items={[\n { id: 'edit', label: t('directory.organizations.list.actions.edit', 'Edit'), href: `/backend/directory/organizations/${row.id}/edit` },\n { id: 'delete', label: t('directory.organizations.list.actions.delete', 'Delete'), destructive: true, onSelect: () => handleDelete(row) },\n ]}\n />\n ) : null\n )}\n emptyState={(\n <ListEmptyState\n entityName={t('directory.organizations.list.title', 'Organizations')}\n createHref=\"/backend/directory/organizations/create\"\n createLabel={t('directory.organizations.list.actions.create', 'Create')}\n />\n )}\n pagination={{ page, pageSize: 50, total, totalPages, onPageChange: setPage }}\n isLoading={isLoading}\n />\n </PageBody>\n {ConfirmDialogElement}\n </Page>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -9,10 +9,11 @@ import { ListEmptyState } from "@open-mercato/ui/backend/filters/ListEmptyState"
|
|
|
9
9
|
import { RowActions } from "@open-mercato/ui/backend/RowActions";
|
|
10
10
|
import { BooleanIcon } from "@open-mercato/ui/backend/ValueIcons";
|
|
11
11
|
import { Button } from "@open-mercato/ui/primitives/button";
|
|
12
|
-
import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from "@open-mercato/ui/backend/utils/apiCall";
|
|
12
|
+
import { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from "@open-mercato/ui/backend/utils/apiCall";
|
|
13
13
|
import { buildOptimisticLockHeader } from "@open-mercato/ui/backend/utils/optimisticLock";
|
|
14
14
|
import { flash } from "@open-mercato/ui/backend/FlashMessages";
|
|
15
|
-
import {
|
|
15
|
+
import { useGuardedMutation } from "@open-mercato/ui/backend/injection/useGuardedMutation";
|
|
16
|
+
import { surfaceRecordConflict } from "@open-mercato/ui/backend/conflicts";
|
|
16
17
|
import { useConfirmDialog } from "@open-mercato/ui/backend/confirm-dialog";
|
|
17
18
|
import { useOrganizationScopeVersion } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
|
|
18
19
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
@@ -95,6 +96,11 @@ function DirectoryTenantsPage() {
|
|
|
95
96
|
const rows = data?.items ?? [];
|
|
96
97
|
const total = data?.total ?? 0;
|
|
97
98
|
const totalPages = data?.totalPages ?? 0;
|
|
99
|
+
const deleteMutationContextId = "directory-tenants-list:single-delete";
|
|
100
|
+
const { runMutation: runDeleteMutation, retryLastMutation: retryDeleteMutation } = useGuardedMutation({
|
|
101
|
+
contextId: deleteMutationContextId,
|
|
102
|
+
blockedMessage: t("ui.forms.flash.saveBlocked", "Save blocked by validation")
|
|
103
|
+
});
|
|
98
104
|
const handleDelete = React.useCallback(async (tenant) => {
|
|
99
105
|
const confirmMessage = t("directory.tenants.list.confirmDelete", 'Delete tenant "{{name}}"? This will archive it.').replace("{{name}}", tenant.name);
|
|
100
106
|
const confirmed = await confirm({
|
|
@@ -104,23 +110,33 @@ function DirectoryTenantsPage() {
|
|
|
104
110
|
});
|
|
105
111
|
if (!confirmed) return;
|
|
106
112
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
await runDeleteMutation({
|
|
114
|
+
operation: () => withScopedApiRequestHeaders(
|
|
115
|
+
buildOptimisticLockHeader(tenant.updatedAt),
|
|
116
|
+
() => apiCallOrThrow(
|
|
117
|
+
`/api/directory/tenants?id=${encodeURIComponent(tenant.id)}`,
|
|
118
|
+
{ method: "DELETE" },
|
|
119
|
+
{ errorMessage: t("directory.tenants.list.error.delete", "Failed to delete tenant") }
|
|
120
|
+
)
|
|
121
|
+
),
|
|
122
|
+
context: {
|
|
123
|
+
formId: deleteMutationContextId,
|
|
124
|
+
resourceKind: "directory.tenant",
|
|
125
|
+
resourceId: tenant.id,
|
|
126
|
+
retryLastMutation: retryDeleteMutation
|
|
127
|
+
}
|
|
128
|
+
});
|
|
117
129
|
await queryClient.invalidateQueries({ queryKey: ["directory-tenants"] });
|
|
118
130
|
flash(t("directory.tenants.list.success.delete", "Tenant deleted"), "success");
|
|
119
131
|
} catch (err) {
|
|
132
|
+
if (surfaceRecordConflict(err, t)) {
|
|
133
|
+
await queryClient.invalidateQueries({ queryKey: ["directory-tenants"] });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
120
136
|
const message = err instanceof Error ? err.message : t("directory.tenants.list.error.delete", "Failed to delete tenant");
|
|
121
137
|
flash(message, "error");
|
|
122
138
|
}
|
|
123
|
-
}, [confirm, queryClient, t]);
|
|
139
|
+
}, [confirm, deleteMutationContextId, queryClient, retryDeleteMutation, runDeleteMutation, t]);
|
|
124
140
|
return /* @__PURE__ */ jsxs(Page, { children: [
|
|
125
141
|
/* @__PURE__ */ jsx(PageBody, { children: /* @__PURE__ */ jsx(
|
|
126
142
|
DataTable,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/directory/backend/directory/tenants/page.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport type { ColumnDef, SortingState } from '@tanstack/react-table'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport { BooleanIcon } from '@open-mercato/ui/backend/ValueIcons'\nimport type { FilterValues } from '@open-mercato/ui/backend/FilterBar'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport {
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useQuery, useQueryClient } from '@tanstack/react-query'\nimport type { ColumnDef, SortingState } from '@tanstack/react-table'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport { ListEmptyState } from '@open-mercato/ui/backend/filters/ListEmptyState'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport { BooleanIcon } from '@open-mercato/ui/backend/ValueIcons'\nimport type { FilterValues } from '@open-mercato/ui/backend/FilterBar'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { apiCall, apiCallOrThrow, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'\nimport { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'\nimport { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype TenantRow = {\n id: string\n name: string\n isActive: boolean\n createdAt: string | null\n updatedAt: string | null\n}\n\ntype TenantsResponse = {\n items: TenantRow[]\n total: number\n page: number\n pageSize: number\n totalPages: number\n}\n\n\nexport default function DirectoryTenantsPage() {\n const queryClient = useQueryClient()\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n const [sorting, setSorting] = React.useState<SortingState>([{ id: 'name', desc: false }])\n const [page, setPage] = React.useState(1)\n const [search, setSearch] = React.useState('')\n const [filters, setFilters] = React.useState<FilterValues>({})\n const [canManage, setCanManage] = React.useState(false)\n const scopeVersion = useOrganizationScopeVersion()\n const t = useT()\n const columns = React.useMemo<ColumnDef<TenantRow>[]>(() => [\n { accessorKey: 'name', header: t('directory.tenants.list.columns.tenant', 'Tenant'), meta: { priority: 1 } },\n {\n accessorKey: 'isActive',\n header: t('directory.tenants.list.columns.active', 'Active'),\n enableSorting: false,\n meta: { priority: 2 },\n cell: ({ getValue }) => <BooleanIcon value={Boolean(getValue())} />,\n },\n {\n accessorKey: 'createdAt',\n header: t('directory.tenants.list.columns.created', 'Created'),\n meta: { priority: 3 },\n cell: ({ getValue }) => {\n const timestamp = getValue() as string | null\n if (!timestamp) return <span className=\"text-xs text-muted-foreground\">\u2014</span>\n const date = new Date(timestamp)\n if (Number.isNaN(date.getTime())) return <span className=\"text-xs text-muted-foreground\">\u2014</span>\n return <span>{date.toLocaleString()}</span>\n },\n },\n ], [t])\n\n React.useEffect(() => {\n let cancelled = false\n async function loadFeature() {\n try {\n const call = await apiCall<{ ok?: boolean; granted?: string[] }>('/api/auth/feature-check', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ features: ['directory.tenants.manage'] }),\n })\n if (!cancelled) {\n const granted = Array.isArray(call.result?.granted) ? call.result!.granted! : []\n setCanManage(call.result?.ok === true || granted.includes('directory.tenants.manage'))\n }\n } catch {\n if (!cancelled) setCanManage(false)\n }\n }\n loadFeature()\n return () => { cancelled = true }\n }, [])\n\n const queryParams = React.useMemo(() => {\n const params = new URLSearchParams()\n params.set('page', String(page))\n params.set('pageSize', '20')\n if (sorting.length > 0) {\n params.set('sortField', sorting[0]?.id || 'name')\n params.set('sortDir', sorting[0]?.desc ? 'desc' : 'asc')\n }\n if (search) params.set('search', search)\n if (filters.active !== undefined && filters.active !== '') params.set('isActive', String(filters.active))\n return params.toString()\n }, [page, sorting, search, filters])\n\n const { data, isLoading } = useQuery({\n queryKey: ['directory-tenants', queryParams, scopeVersion],\n queryFn: async (): Promise<TenantsResponse> => {\n return readApiResultOrThrow<TenantsResponse>(\n `/api/directory/tenants?${queryParams}`,\n undefined,\n { errorMessage: t('directory.tenants.list.error.load', 'Failed to load tenants') },\n )\n },\n })\n\n const rows = data?.items ?? []\n const total = data?.total ?? 0\n const totalPages = data?.totalPages ?? 0\n\n const deleteMutationContextId = 'directory-tenants-list:single-delete'\n const { runMutation: runDeleteMutation, retryLastMutation: retryDeleteMutation } = useGuardedMutation<{\n formId: string\n resourceKind: string\n resourceId: string\n retryLastMutation: () => Promise<boolean>\n }>({\n contextId: deleteMutationContextId,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n const handleDelete = React.useCallback(async (tenant: TenantRow) => {\n const confirmMessage = t('directory.tenants.list.confirmDelete', 'Delete tenant \"{{name}}\"? This will archive it.').replace('{{name}}', tenant.name)\n const confirmed = await confirm({\n title: t('common.delete', 'Delete'),\n text: confirmMessage,\n variant: 'destructive',\n })\n if (!confirmed) return\n\n try {\n await runDeleteMutation({\n operation: () => withScopedApiRequestHeaders(\n buildOptimisticLockHeader(tenant.updatedAt),\n () => apiCallOrThrow(\n `/api/directory/tenants?id=${encodeURIComponent(tenant.id)}`,\n { method: 'DELETE' },\n { errorMessage: t('directory.tenants.list.error.delete', 'Failed to delete tenant') },\n ),\n ),\n context: {\n formId: deleteMutationContextId,\n resourceKind: 'directory.tenant',\n resourceId: tenant.id,\n retryLastMutation: retryDeleteMutation,\n },\n })\n await queryClient.invalidateQueries({ queryKey: ['directory-tenants'] })\n flash(t('directory.tenants.list.success.delete', 'Tenant deleted'), 'success')\n } catch (err: unknown) {\n // A stale delete surfaces the unified conflict bar (via the guarded\n // mutation) \u2014 skip the generic error flash to avoid a double message.\n if (surfaceRecordConflict(err, t)) {\n await queryClient.invalidateQueries({ queryKey: ['directory-tenants'] })\n return\n }\n const message = err instanceof Error ? err.message : t('directory.tenants.list.error.delete', 'Failed to delete tenant')\n flash(message, 'error')\n }\n }, [confirm, deleteMutationContextId, queryClient, retryDeleteMutation, runDeleteMutation, t])\n\n return (\n <Page>\n <PageBody>\n <DataTable\n title={t('directory.tenants.list.title', 'Tenants')}\n actions={canManage ? (\n <Button asChild>\n <Link href=\"/backend/directory/tenants/create\">{t('directory.tenants.list.actions.create', 'Create')}</Link>\n </Button>\n ) : undefined}\n columns={columns}\n data={rows}\n searchValue={search}\n onSearchChange={(value) => { setSearch(value); setPage(1) }}\n filters={[{ id: 'active', label: t('directory.tenants.list.filters.status', 'Status'), type: 'select', options: [\n { value: 'true', label: t('directory.tenants.list.filters.active', 'Active') },\n { value: 'false', label: t('directory.tenants.list.filters.inactive', 'Inactive') },\n ] }]}\n filterValues={filters}\n onFiltersApply={(vals) => { setFilters(vals); setPage(1) }}\n onFiltersClear={() => { setFilters({}); setPage(1) }}\n sortable\n sorting={sorting}\n onSortingChange={(state) => { setSorting(state); setPage(1) }}\n perspective={{ tableId: 'directory.tenants.list' }}\n rowActions={(row) => (\n canManage ? (\n <RowActions\n items={[\n { id: 'edit', label: t('common.edit', 'Edit'), href: `/backend/directory/tenants/${row.id}/edit` },\n { id: 'delete', label: t('common.delete', 'Delete'), destructive: true, onSelect: () => handleDelete(row) },\n ]}\n />\n ) : null\n )}\n emptyState={(\n <ListEmptyState\n entityName={t('directory.tenants.list.title', 'Tenants')}\n createHref=\"/backend/directory/tenants/create\"\n createLabel={t('directory.tenants.list.actions.create', 'Create')}\n />\n )}\n pagination={{ page, pageSize: 20, total, totalPages, onPageChange: setPage }}\n isLoading={isLoading}\n />\n </PageBody>\n {ConfirmDialogElement}\n </Page>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -6,6 +6,10 @@ import { customFieldEntityConfigSchema, upsertCustomFieldDefSchema } from "@open
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { invalidateDefinitionsCache } from "./definitions.cache.js";
|
|
8
8
|
import { mergeEntityFieldsetConfig, normalizeEntityFieldsetConfig } from "../lib/fieldsets.js";
|
|
9
|
+
import {
|
|
10
|
+
beginEntitiesMutationGuard,
|
|
11
|
+
FIELD_DEFINITION_RESOURCE_KIND
|
|
12
|
+
} from "./definitions.mutation-guard.js";
|
|
9
13
|
const metadata = {
|
|
10
14
|
POST: { requireAuth: true, requireFeatures: ["entities.definitions.manage"] }
|
|
11
15
|
};
|
|
@@ -52,6 +56,16 @@ async function POST(req) {
|
|
|
52
56
|
cache = resolve("cache");
|
|
53
57
|
} catch {
|
|
54
58
|
}
|
|
59
|
+
const guard = await beginEntitiesMutationGuard({
|
|
60
|
+
container,
|
|
61
|
+
auth,
|
|
62
|
+
req,
|
|
63
|
+
resourceKind: FIELD_DEFINITION_RESOURCE_KIND,
|
|
64
|
+
resourceId: entityId,
|
|
65
|
+
operation: "custom",
|
|
66
|
+
mutationPayload: { entityId, definitionCount: definitions.length }
|
|
67
|
+
});
|
|
68
|
+
if (guard.blockedResponse) return guard.blockedResponse;
|
|
55
69
|
await em.begin();
|
|
56
70
|
try {
|
|
57
71
|
const defByKey = /* @__PURE__ */ new Map();
|
|
@@ -116,6 +130,7 @@ async function POST(req) {
|
|
|
116
130
|
}
|
|
117
131
|
return NextResponse.json({ error: "Failed to save definitions batch" }, { status: 500 });
|
|
118
132
|
}
|
|
133
|
+
await guard.runAfterSuccess();
|
|
119
134
|
await invalidateDefinitionsCache(cache, {
|
|
120
135
|
tenantId: auth.tenantId ?? null,
|
|
121
136
|
organizationId: auth.orgId ?? null,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/entities/api/definitions.batch.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { CustomFieldDef, CustomFieldEntityConfig } from '@open-mercato/core/modules/entities/data/entities'\nimport { customFieldEntityConfigSchema, upsertCustomFieldDefSchema } from '@open-mercato/core/modules/entities/data/validators'\nimport { z } from 'zod'\nimport { invalidateDefinitionsCache } from './definitions.cache'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { mergeEntityFieldsetConfig, normalizeEntityFieldsetConfig } from '../lib/fieldsets'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },\n}\n\nconst MAX_DEFINITIONS_PER_BATCH = 1000\n\nconst batchSchema = z\n .object({\n entityId: z.string().regex(/^[a-z0-9_]+:[a-z0-9_]+$/),\n definitions: z\n .array(\n upsertCustomFieldDefSchema\n .omit({ entityId: true })\n .extend({\n configJson: z.any().optional(),\n })\n )\n .max(MAX_DEFINITIONS_PER_BATCH),\n })\n .extend(customFieldEntityConfigSchema.shape)\n\ntype IncomingFieldset = z.infer<typeof customFieldEntityConfigSchema>['fieldsets']\n\nfunction cloneFieldsets(fieldsets?: IncomingFieldset): IncomingFieldset {\n if (!Array.isArray(fieldsets)) return undefined\n return fieldsets.map((fieldset) => ({\n code: fieldset.code,\n label: fieldset.label,\n icon: fieldset.icon,\n description: fieldset.description,\n groups: Array.isArray(fieldset.groups)\n ? fieldset.groups.map((group) => ({\n code: group.code,\n title: group.title,\n hint: group.hint,\n }))\n : undefined,\n }))\n}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.orgId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n let body: any\n try { body = await req.json() } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) }\n\n const parsed = batchSchema.safeParse(body)\n if (!parsed.success) return NextResponse.json({ error: 'Validation failed', details: parsed.error.flatten() }, { status: 400 })\n const { entityId, definitions, fieldsets, singleFieldsetPerRecord } = parsed.data\n\n const container = await createRequestContainer()\n const { resolve } = container\n const em = resolve('em') as any\n let cache: CacheStrategy | undefined\n try {\n cache = resolve('cache') as CacheStrategy\n } catch {}\n\n await em.begin()\n try {\n // Prefetch every existing definition for this entity in a single query, then index\n // by key so the per-definition loop resolves create/update without round trips.\n const defByKey = new Map<string, any>()\n const keys = definitions.map((d) => d.key)\n if (keys.length > 0) {\n const existingDefs = await em.find(CustomFieldDef, {\n entityId,\n key: { $in: keys },\n organizationId: auth.orgId ?? null,\n tenantId: auth.tenantId ?? null,\n })\n for (const existing of existingDefs) defByKey.set(existing.key, existing)\n }\n\n for (const [idx, d] of definitions.entries()) {\n const where: any = {\n entityId,\n key: d.key,\n organizationId: auth.orgId ?? null,\n tenantId: auth.tenantId ?? null,\n }\n let def = defByKey.get(d.key)\n if (!def) {\n def = em.create(CustomFieldDef, { ...where, createdAt: new Date() })\n defByKey.set(d.key, def)\n }\n def.kind = d.kind\n\n const inCfg = (d as any).configJson ?? {}\n const cfg: Record<string, any> = { ...inCfg }\n if (cfg.label == null || String(cfg.label).trim() === '') cfg.label = d.key\n if (cfg.formEditable === undefined) cfg.formEditable = true\n if (cfg.listVisible === undefined) cfg.listVisible = true\n if (d.kind === 'multiline' && (cfg.editor == null || String(cfg.editor).trim() === '')) cfg.editor = 'markdown'\n cfg.priority = idx\n\n def.configJson = cfg\n def.isActive = d.isActive ?? true\n def.updatedAt = new Date()\n em.persist(def)\n }\n if (fieldsets !== undefined || singleFieldsetPerRecord !== undefined) {\n const scope: any = { entityId, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null }\n let cfg = await em.findOne(CustomFieldEntityConfig, scope)\n if (!cfg) cfg = em.create(CustomFieldEntityConfig, { ...scope, createdAt: new Date() })\n const existing = normalizeEntityFieldsetConfig(cfg.configJson ?? {})\n const patch = mergeEntityFieldsetConfig(existing, {\n fieldsets: fieldsets !== undefined ? cloneFieldsets(fieldsets) ?? [] : undefined,\n singleFieldsetPerRecord,\n })\n cfg.configJson = {\n fieldsets: patch.fieldsets,\n singleFieldsetPerRecord: patch.singleFieldsetPerRecord,\n }\n cfg.updatedAt = new Date()\n cfg.isActive = true\n em.persist(cfg)\n }\n await em.flush()\n await em.commit()\n } catch (e) {\n try { await em.rollback() } catch {}\n return NextResponse.json({ error: 'Failed to save definitions batch' }, { status: 500 })\n }\n\n await invalidateDefinitionsCache(cache, {\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n entityIds: [entityId],\n })\n\n return NextResponse.json({ ok: true })\n}\n\nconst batchResponseSchema = z.object({\n ok: z.literal(true),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Entities',\n summary: 'Batch upsert custom field definitions',\n methods: {\n POST: {\n summary: 'Save multiple custom field definitions',\n description: 'Creates or updates multiple definitions for a single entity in one transaction.',\n requestBody: {\n contentType: 'application/json',\n schema: batchSchema,\n },\n responses: [\n {\n status: 200,\n description: 'Definitions saved',\n schema: batchResponseSchema,\n },\n {\n status: 400,\n description: 'Validation error',\n schema: z.object({\n error: z.string(),\n details: z.any().optional(),\n }),\n },\n {\n status: 401,\n description: 'Missing authentication',\n schema: z.object({ error: z.string() }),\n },\n {\n status: 500,\n description: 'Unexpected failure',\n schema: z.object({ error: z.string() }),\n },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAE7B,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,gBAAgB,+BAA+B;AACxD,SAAS,+BAA+B,kCAAkC;AAC1E,SAAS,SAAS;AAClB,SAAS,kCAAkC;AAE3C,SAAS,2BAA2B,qCAAqC;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { CustomFieldDef, CustomFieldEntityConfig } from '@open-mercato/core/modules/entities/data/entities'\nimport { customFieldEntityConfigSchema, upsertCustomFieldDefSchema } from '@open-mercato/core/modules/entities/data/validators'\nimport { z } from 'zod'\nimport { invalidateDefinitionsCache } from './definitions.cache'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { mergeEntityFieldsetConfig, normalizeEntityFieldsetConfig } from '../lib/fieldsets'\nimport {\n beginEntitiesMutationGuard,\n FIELD_DEFINITION_RESOURCE_KIND,\n} from './definitions.mutation-guard'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },\n}\n\nconst MAX_DEFINITIONS_PER_BATCH = 1000\n\nconst batchSchema = z\n .object({\n entityId: z.string().regex(/^[a-z0-9_]+:[a-z0-9_]+$/),\n definitions: z\n .array(\n upsertCustomFieldDefSchema\n .omit({ entityId: true })\n .extend({\n configJson: z.any().optional(),\n })\n )\n .max(MAX_DEFINITIONS_PER_BATCH),\n })\n .extend(customFieldEntityConfigSchema.shape)\n\ntype IncomingFieldset = z.infer<typeof customFieldEntityConfigSchema>['fieldsets']\n\nfunction cloneFieldsets(fieldsets?: IncomingFieldset): IncomingFieldset {\n if (!Array.isArray(fieldsets)) return undefined\n return fieldsets.map((fieldset) => ({\n code: fieldset.code,\n label: fieldset.label,\n icon: fieldset.icon,\n description: fieldset.description,\n groups: Array.isArray(fieldset.groups)\n ? fieldset.groups.map((group) => ({\n code: group.code,\n title: group.title,\n hint: group.hint,\n }))\n : undefined,\n }))\n}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth || !auth.orgId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n let body: any\n try { body = await req.json() } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) }\n\n const parsed = batchSchema.safeParse(body)\n if (!parsed.success) return NextResponse.json({ error: 'Validation failed', details: parsed.error.flatten() }, { status: 400 })\n const { entityId, definitions, fieldsets, singleFieldsetPerRecord } = parsed.data\n\n const container = await createRequestContainer()\n const { resolve } = container\n const em = resolve('em') as any\n let cache: CacheStrategy | undefined\n try {\n cache = resolve('cache') as CacheStrategy\n } catch {}\n\n const guard = await beginEntitiesMutationGuard({\n container,\n auth,\n req,\n resourceKind: FIELD_DEFINITION_RESOURCE_KIND,\n resourceId: entityId,\n operation: 'custom',\n mutationPayload: { entityId, definitionCount: definitions.length },\n })\n if (guard.blockedResponse) return guard.blockedResponse\n\n await em.begin()\n try {\n // Prefetch every existing definition for this entity in a single query, then index\n // by key so the per-definition loop resolves create/update without round trips.\n const defByKey = new Map<string, any>()\n const keys = definitions.map((d) => d.key)\n if (keys.length > 0) {\n const existingDefs = await em.find(CustomFieldDef, {\n entityId,\n key: { $in: keys },\n organizationId: auth.orgId ?? null,\n tenantId: auth.tenantId ?? null,\n })\n for (const existing of existingDefs) defByKey.set(existing.key, existing)\n }\n\n for (const [idx, d] of definitions.entries()) {\n const where: any = {\n entityId,\n key: d.key,\n organizationId: auth.orgId ?? null,\n tenantId: auth.tenantId ?? null,\n }\n let def = defByKey.get(d.key)\n if (!def) {\n def = em.create(CustomFieldDef, { ...where, createdAt: new Date() })\n defByKey.set(d.key, def)\n }\n def.kind = d.kind\n\n const inCfg = (d as any).configJson ?? {}\n const cfg: Record<string, any> = { ...inCfg }\n if (cfg.label == null || String(cfg.label).trim() === '') cfg.label = d.key\n if (cfg.formEditable === undefined) cfg.formEditable = true\n if (cfg.listVisible === undefined) cfg.listVisible = true\n if (d.kind === 'multiline' && (cfg.editor == null || String(cfg.editor).trim() === '')) cfg.editor = 'markdown'\n cfg.priority = idx\n\n def.configJson = cfg\n def.isActive = d.isActive ?? true\n def.updatedAt = new Date()\n em.persist(def)\n }\n if (fieldsets !== undefined || singleFieldsetPerRecord !== undefined) {\n const scope: any = { entityId, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null }\n let cfg = await em.findOne(CustomFieldEntityConfig, scope)\n if (!cfg) cfg = em.create(CustomFieldEntityConfig, { ...scope, createdAt: new Date() })\n const existing = normalizeEntityFieldsetConfig(cfg.configJson ?? {})\n const patch = mergeEntityFieldsetConfig(existing, {\n fieldsets: fieldsets !== undefined ? cloneFieldsets(fieldsets) ?? [] : undefined,\n singleFieldsetPerRecord,\n })\n cfg.configJson = {\n fieldsets: patch.fieldsets,\n singleFieldsetPerRecord: patch.singleFieldsetPerRecord,\n }\n cfg.updatedAt = new Date()\n cfg.isActive = true\n em.persist(cfg)\n }\n await em.flush()\n await em.commit()\n } catch (e) {\n try { await em.rollback() } catch {}\n return NextResponse.json({ error: 'Failed to save definitions batch' }, { status: 500 })\n }\n\n await guard.runAfterSuccess()\n\n await invalidateDefinitionsCache(cache, {\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n entityIds: [entityId],\n })\n\n return NextResponse.json({ ok: true })\n}\n\nconst batchResponseSchema = z.object({\n ok: z.literal(true),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Entities',\n summary: 'Batch upsert custom field definitions',\n methods: {\n POST: {\n summary: 'Save multiple custom field definitions',\n description: 'Creates or updates multiple definitions for a single entity in one transaction.',\n requestBody: {\n contentType: 'application/json',\n schema: batchSchema,\n },\n responses: [\n {\n status: 200,\n description: 'Definitions saved',\n schema: batchResponseSchema,\n },\n {\n status: 400,\n description: 'Validation error',\n schema: z.object({\n error: z.string(),\n details: z.any().optional(),\n }),\n },\n {\n status: 401,\n description: 'Missing authentication',\n schema: z.object({ error: z.string() }),\n },\n {\n status: 500,\n description: 'Unexpected failure',\n schema: z.object({ error: z.string() }),\n },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -16,6 +16,10 @@ import { loadEntityFieldsetConfigs } from "../lib/fieldsets.js";
|
|
|
16
16
|
import { installCustomEntitiesFromModules } from "../lib/install-from-ce.js";
|
|
17
17
|
import { normalizeCustomFieldOptions } from "@open-mercato/shared/modules/entities/options";
|
|
18
18
|
import { CURRENCY_OPTIONS_URL } from "@open-mercato/shared/modules/entities/kinds";
|
|
19
|
+
import {
|
|
20
|
+
beginEntitiesMutationGuard,
|
|
21
|
+
FIELD_DEFINITION_RESOURCE_KIND
|
|
22
|
+
} from "./definitions.mutation-guard.js";
|
|
19
23
|
async function validateDefaultValueByKind(value, kind, cfg, em, tenantContext) {
|
|
20
24
|
switch (kind) {
|
|
21
25
|
case "boolean":
|
|
@@ -412,6 +416,16 @@ async function POST(req) {
|
|
|
412
416
|
}
|
|
413
417
|
const where = { entityId: input.entityId, key: input.key, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null };
|
|
414
418
|
let def = await em.findOne(CustomFieldDef, where);
|
|
419
|
+
const guard = await beginEntitiesMutationGuard({
|
|
420
|
+
container,
|
|
421
|
+
auth,
|
|
422
|
+
req,
|
|
423
|
+
resourceKind: FIELD_DEFINITION_RESOURCE_KIND,
|
|
424
|
+
resourceId: def ? def.id : `${input.entityId}:${input.key}`,
|
|
425
|
+
operation: def ? "update" : "create",
|
|
426
|
+
mutationPayload: input
|
|
427
|
+
});
|
|
428
|
+
if (guard.blockedResponse) return guard.blockedResponse;
|
|
415
429
|
if (!def) def = em.create(CustomFieldDef, { ...where, createdAt: /* @__PURE__ */ new Date() });
|
|
416
430
|
def.kind = input.kind;
|
|
417
431
|
const inCfg = input.configJson ?? {};
|
|
@@ -449,6 +463,7 @@ async function POST(req) {
|
|
|
449
463
|
def.updatedAt = /* @__PURE__ */ new Date();
|
|
450
464
|
em.persist(def);
|
|
451
465
|
await em.flush();
|
|
466
|
+
await guard.runAfterSuccess();
|
|
452
467
|
await invalidateDefinitionsCache(cache, {
|
|
453
468
|
tenantId: auth.tenantId ?? null,
|
|
454
469
|
organizationId: auth.orgId ?? null,
|
|
@@ -478,11 +493,22 @@ async function DELETE(req) {
|
|
|
478
493
|
const where = { entityId, key, organizationId: auth.orgId ?? null, tenantId: auth.tenantId ?? null };
|
|
479
494
|
const def = await em.findOne(CustomFieldDef, where);
|
|
480
495
|
if (!def) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
496
|
+
const guard = await beginEntitiesMutationGuard({
|
|
497
|
+
container,
|
|
498
|
+
auth,
|
|
499
|
+
req,
|
|
500
|
+
resourceKind: FIELD_DEFINITION_RESOURCE_KIND,
|
|
501
|
+
resourceId: def.id,
|
|
502
|
+
operation: "delete",
|
|
503
|
+
mutationPayload: { entityId, key }
|
|
504
|
+
});
|
|
505
|
+
if (guard.blockedResponse) return guard.blockedResponse;
|
|
481
506
|
def.isActive = false;
|
|
482
507
|
def.updatedAt = /* @__PURE__ */ new Date();
|
|
483
508
|
def.deletedAt = def.deletedAt ?? /* @__PURE__ */ new Date();
|
|
484
509
|
em.persist(def);
|
|
485
510
|
await em.flush();
|
|
511
|
+
await guard.runAfterSuccess();
|
|
486
512
|
await invalidateDefinitionsCache(cache, {
|
|
487
513
|
tenantId: auth.tenantId ?? null,
|
|
488
514
|
organizationId: auth.orgId ?? null,
|