@open-mercato/core 0.6.6-develop.6404.1.e2f8d5025a → 0.6.6-develop.6407.1.9a4c26cf6a
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/api/route.js +10 -4
- package/dist/modules/attachments/api/route.js.map +2 -2
- package/dist/modules/catalog/api/settings/route.js +142 -0
- package/dist/modules/catalog/api/settings/route.js.map +7 -0
- package/dist/modules/catalog/backend/config/catalog/page.js +6 -2
- package/dist/modules/catalog/backend/config/catalog/page.js.map +2 -2
- package/dist/modules/catalog/components/UnitPriceDisplaySettings.js +89 -0
- package/dist/modules/catalog/components/UnitPriceDisplaySettings.js.map +7 -0
- package/dist/modules/catalog/components/products/ProductUomSection.js +4 -2
- package/dist/modules/catalog/components/products/ProductUomSection.js.map +2 -2
- package/dist/modules/catalog/components/products/hooks/useUnitPriceDisplayEnabled.js +25 -0
- package/dist/modules/catalog/components/products/hooks/useUnitPriceDisplayEnabled.js.map +7 -0
- package/dist/modules/catalog/lib/settings.js +9 -0
- package/dist/modules/catalog/lib/settings.js.map +7 -0
- package/dist/modules/customers/api/pipeline-stages/route.js +6 -4
- package/dist/modules/customers/api/pipeline-stages/route.js.map +2 -2
- package/dist/modules/customers/api/pipelines/route.js +5 -3
- package/dist/modules/customers/api/pipelines/route.js.map +2 -2
- package/dist/modules/staff/api/team-members/assignable/route.js +7 -11
- package/dist/modules/staff/api/team-members/assignable/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/api/route.ts +20 -4
- package/src/modules/attachments/i18n/de.json +1 -0
- package/src/modules/attachments/i18n/en.json +1 -0
- package/src/modules/attachments/i18n/es.json +1 -0
- package/src/modules/attachments/i18n/pl.json +1 -0
- package/src/modules/catalog/api/settings/route.ts +165 -0
- package/src/modules/catalog/backend/config/catalog/page.tsx +2 -0
- package/src/modules/catalog/components/UnitPriceDisplaySettings.tsx +106 -0
- package/src/modules/catalog/components/products/ProductUomSection.tsx +4 -0
- package/src/modules/catalog/components/products/hooks/useUnitPriceDisplayEnabled.ts +39 -0
- package/src/modules/catalog/i18n/de.json +8 -0
- package/src/modules/catalog/i18n/en.json +8 -0
- package/src/modules/catalog/i18n/es.json +8 -0
- package/src/modules/catalog/i18n/pl.json +8 -0
- package/src/modules/catalog/lib/settings.ts +8 -0
- package/src/modules/customers/api/pipeline-stages/route.ts +6 -4
- package/src/modules/customers/api/pipelines/route.ts +5 -3
- package/src/modules/staff/api/team-members/assignable/route.ts +8 -12
|
@@ -4,6 +4,7 @@ import { CrudHttpError, isCrudHttpError } from "@open-mercato/shared/lib/crud/er
|
|
|
4
4
|
import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
5
5
|
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
6
6
|
import { createPagedListResponseSchema as createSharedPagedListResponseSchema } from "@open-mercato/shared/lib/openapi/crud";
|
|
7
|
+
import { resolveOrganizationScopeFilter } from "@open-mercato/core/modules/directory/utils/organizationScopeFilter";
|
|
7
8
|
import { User } from "@open-mercato/core/modules/auth/data/entities";
|
|
8
9
|
import {
|
|
9
10
|
resolveAuthActorId,
|
|
@@ -49,16 +50,11 @@ async function GET(request) {
|
|
|
49
50
|
const { translate } = await resolveTranslations();
|
|
50
51
|
try {
|
|
51
52
|
const query = querySchema.parse(Object.fromEntries(new URL(request.url).searchParams));
|
|
52
|
-
const { container, em, auth,
|
|
53
|
-
|
|
54
|
-
throw new CrudHttpError(
|
|
55
|
-
400,
|
|
56
|
-
{ error: translate("customers.errors.organization_required", "Organization context is required") }
|
|
57
|
-
);
|
|
58
|
-
}
|
|
53
|
+
const { container, em, auth, scope: organizationScope } = await resolveCustomersRequestContext(request);
|
|
54
|
+
const orgFilter = resolveOrganizationScopeFilter(organizationScope, auth);
|
|
59
55
|
const actorId = resolveAuthActorId(auth);
|
|
60
56
|
const rbacService = container.resolve("rbacService");
|
|
61
|
-
const scope = { tenantId: auth.tenantId, organizationId:
|
|
57
|
+
const scope = { tenantId: auth.tenantId, organizationId: orgFilter.rbacOrganizationId };
|
|
62
58
|
const hasAccess = await canAccessAssignableStaff(rbacService, actorId, scope);
|
|
63
59
|
if (!hasAccess) {
|
|
64
60
|
throw new CrudHttpError(
|
|
@@ -77,7 +73,7 @@ async function GET(request) {
|
|
|
77
73
|
StaffTeamMember,
|
|
78
74
|
{
|
|
79
75
|
tenantId: auth.tenantId,
|
|
80
|
-
|
|
76
|
+
...orgFilter.where,
|
|
81
77
|
deletedAt: null,
|
|
82
78
|
isActive: true
|
|
83
79
|
},
|
|
@@ -102,7 +98,7 @@ async function GET(request) {
|
|
|
102
98
|
id: { $in: userIds },
|
|
103
99
|
deletedAt: null,
|
|
104
100
|
tenantId: auth.tenantId,
|
|
105
|
-
|
|
101
|
+
...orgFilter.where
|
|
106
102
|
},
|
|
107
103
|
void 0,
|
|
108
104
|
scope
|
|
@@ -114,7 +110,7 @@ async function GET(request) {
|
|
|
114
110
|
id: { $in: teamIds },
|
|
115
111
|
deletedAt: null,
|
|
116
112
|
tenantId: auth.tenantId,
|
|
117
|
-
|
|
113
|
+
...orgFilter.where
|
|
118
114
|
},
|
|
119
115
|
void 0,
|
|
120
116
|
scope
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/staff/api/team-members/assignable/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createPagedListResponseSchema as createSharedPagedListResponseSchema } from '@open-mercato/shared/lib/openapi/crud'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport {\n resolveAuthActorId,\n resolveCustomersRequestContext,\n} from '@open-mercato/core/modules/customers/lib/interactionRequestContext'\nimport { StaffTeam, StaffTeamMember } from '../../../data/entities'\n\nconst querySchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(24),\n search: z.string().optional(),\n })\n .passthrough()\n\nconst itemSchema = z.object({\n id: z.string().uuid(),\n teamMemberId: z.string().uuid(),\n userId: z.string().uuid(),\n displayName: z.string(),\n email: z.string().nullable().optional(),\n teamName: z.string().nullable().optional(),\n user: z\n .object({\n id: z.string().uuid(),\n email: z.string().nullable().optional(),\n })\n .nullable()\n .optional(),\n team: z\n .object({\n id: z.string().uuid(),\n name: z.string().nullable().optional(),\n })\n .nullable()\n .optional(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nconst pagedListSchema = createSharedPagedListResponseSchema(itemSchema, {\n paginationMetaOptional: true,\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.roles.view'] },\n}\n\nasync function canAccessAssignableStaff(\n rbac: RbacService | undefined,\n userId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<boolean> {\n if (!rbac) return false\n if (\n await rbac.userHasAllFeatures(userId, ['customers.roles.manage'], scope)\n ) {\n return true\n }\n return rbac.userHasAllFeatures(userId, ['customers.activities.manage'], scope)\n}\n\nexport async function GET(request: Request) {\n const { translate } = await resolveTranslations()\n try {\n const query = querySchema.parse(Object.fromEntries(new URL(request.url).searchParams))\n const { container, em, auth,
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,eAAe,uBAAuB;AAC/C,SAAS,0BAA0B;AACnC,SAAS,2BAA2B;AACpC,SAAS,iCAAiC,2CAA2C;AAErF,SAAS,YAAY;AACrB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,WAAW,uBAAuB;AAE3C,MAAM,cAAc,EACjB,OAAO;AAAA,EACN,MAAM,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC;AAAA,EACxC,UAAU,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE;AAAA,EACtD,QAAQ,EAAE,OAAO,EAAE,SAAS;AAC9B,CAAC,EACA,YAAY;AAEf,MAAM,aAAa,EAAE,OAAO;AAAA,EAC1B,IAAI,EAAE,OAAO,EAAE,KAAK;AAAA,EACpB,cAAc,EAAE,OAAO,EAAE,KAAK;AAAA,EAC9B,QAAQ,EAAE,OAAO,EAAE,KAAK;AAAA,EACxB,aAAa,EAAE,OAAO;AAAA,EACtB,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACtC,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACzC,MAAM,EACH,OAAO;AAAA,IACN,IAAI,EAAE,OAAO,EAAE,KAAK;AAAA,IACpB,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACxC,CAAC,EACA,SAAS,EACT,SAAS;AAAA,EACZ,MAAM,EACH,OAAO;AAAA,IACN,IAAI,EAAE,OAAO,EAAE,KAAK;AAAA,IACpB,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACvC,CAAC,EACA,SAAS,EACT,SAAS;AACd,CAAC;AAED,MAAM,cAAc,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AAElD,MAAM,kBAAkB,oCAAoC,YAAY;AAAA,EACtE,wBAAwB;AAC1B,CAAC;AAEM,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,sBAAsB,EAAE;AACtE;AAEA,eAAe,yBACb,MACA,QACA,OACkB;AAClB,MAAI,CAAC,KAAM,QAAO;AAClB,MACE,MAAM,KAAK,mBAAmB,QAAQ,CAAC,wBAAwB,GAAG,KAAK,GACvE;AACA,WAAO;AAAA,EACT;AACA,SAAO,KAAK,mBAAmB,QAAQ,CAAC,6BAA6B,GAAG,KAAK;AAC/E;AAEA,eAAsB,IAAI,SAAkB;AAC1C,QAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,MAAI;AACF,UAAM,QAAQ,YAAY,MAAM,OAAO,YAAY,IAAI,IAAI,QAAQ,GAAG,EAAE,YAAY,CAAC;AACrF,UAAM,EAAE,WAAW,IAAI,MAAM,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createPagedListResponseSchema as createSharedPagedListResponseSchema } from '@open-mercato/shared/lib/openapi/crud'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport {\n resolveAuthActorId,\n resolveCustomersRequestContext,\n} from '@open-mercato/core/modules/customers/lib/interactionRequestContext'\nimport { StaffTeam, StaffTeamMember } from '../../../data/entities'\n\nconst querySchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(24),\n search: z.string().optional(),\n })\n .passthrough()\n\nconst itemSchema = z.object({\n id: z.string().uuid(),\n teamMemberId: z.string().uuid(),\n userId: z.string().uuid(),\n displayName: z.string(),\n email: z.string().nullable().optional(),\n teamName: z.string().nullable().optional(),\n user: z\n .object({\n id: z.string().uuid(),\n email: z.string().nullable().optional(),\n })\n .nullable()\n .optional(),\n team: z\n .object({\n id: z.string().uuid(),\n name: z.string().nullable().optional(),\n })\n .nullable()\n .optional(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nconst pagedListSchema = createSharedPagedListResponseSchema(itemSchema, {\n paginationMetaOptional: true,\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.roles.view'] },\n}\n\nasync function canAccessAssignableStaff(\n rbac: RbacService | undefined,\n userId: string,\n scope: { tenantId: string; organizationId: string | null },\n): Promise<boolean> {\n if (!rbac) return false\n if (\n await rbac.userHasAllFeatures(userId, ['customers.roles.manage'], scope)\n ) {\n return true\n }\n return rbac.userHasAllFeatures(userId, ['customers.activities.manage'], scope)\n}\n\nexport async function GET(request: Request) {\n const { translate } = await resolveTranslations()\n try {\n const query = querySchema.parse(Object.fromEntries(new URL(request.url).searchParams))\n const { container, em, auth, scope: organizationScope } = await resolveCustomersRequestContext(request)\n\n const orgFilter = resolveOrganizationScopeFilter(organizationScope, auth)\n\n const actorId = resolveAuthActorId(auth)\n const rbacService = container.resolve('rbacService') as RbacService | undefined\n const scope = { tenantId: auth.tenantId, organizationId: orgFilter.rbacOrganizationId }\n const hasAccess = await canAccessAssignableStaff(rbacService, actorId, scope)\n if (!hasAccess) {\n throw new CrudHttpError(\n 403,\n {\n error: translate(\n 'customers.assignableStaff.forbidden',\n 'Insufficient permissions to load assignable staff.',\n ),\n },\n )\n }\n\n const normalizedSearch = query.search?.trim().toLowerCase() ?? ''\n\n const members = await findWithDecryption(\n em,\n StaffTeamMember,\n {\n tenantId: auth.tenantId,\n ...orgFilter.where,\n deletedAt: null,\n isActive: true,\n },\n { orderBy: { displayName: 'asc' } },\n scope,\n )\n\n const userIds = Array.from(\n new Set(\n members\n .map((member) => (typeof member.userId === 'string' && member.userId.trim().length > 0 ? member.userId : null))\n .filter((value): value is string => typeof value === 'string'),\n ),\n )\n const teamIds = Array.from(\n new Set(\n members\n .map((member) => (typeof member.teamId === 'string' && member.teamId.trim().length > 0 ? member.teamId : null))\n .filter((value): value is string => typeof value === 'string'),\n ),\n )\n\n const [users, teams] = await Promise.all([\n userIds.length > 0\n ? findWithDecryption(\n em,\n User,\n {\n id: { $in: userIds },\n deletedAt: null,\n tenantId: auth.tenantId,\n ...orgFilter.where,\n },\n undefined,\n scope,\n )\n : Promise.resolve([]),\n teamIds.length > 0\n ? findWithDecryption(\n em,\n StaffTeam,\n {\n id: { $in: teamIds },\n deletedAt: null,\n tenantId: auth.tenantId,\n ...orgFilter.where,\n },\n undefined,\n scope,\n )\n : Promise.resolve([]),\n ])\n\n const userById = new Map(\n users.map((user) => [\n user.id,\n {\n id: user.id,\n email: user.email ?? null,\n },\n ]),\n )\n const teamById = new Map(\n teams.map((team) => [\n team.id,\n {\n id: team.id,\n name: team.name ?? null,\n },\n ]),\n )\n\n const items = members\n .filter((member) => typeof member.userId === 'string' && member.userId.trim().length > 0)\n .map((member) => {\n const userId = member.userId as string\n const user = userById.get(userId) ?? { id: userId, email: null }\n const team = member.teamId ? teamById.get(member.teamId) ?? null : null\n return {\n id: member.id,\n teamMemberId: member.id,\n userId,\n displayName: member.displayName?.trim() || user.email || userId,\n email: user.email,\n teamName: team?.name ?? null,\n user,\n team,\n }\n })\n .filter((item) => {\n if (!normalizedSearch) return true\n const haystack = [item.displayName, item.email, item.teamName]\n .filter((value): value is string => typeof value === 'string' && value.length > 0)\n .join(' ')\n .toLowerCase()\n return haystack.includes(normalizedSearch)\n })\n\n const deduped = Array.from(\n items.reduce((acc, item) => {\n if (!acc.has(item.userId)) {\n acc.set(item.userId, item)\n }\n return acc\n }, new Map<string, (typeof items)[number]>()),\n ).map(([, item]) => item)\n\n const start = (query.page - 1) * query.pageSize\n return NextResponse.json({\n items: deduped.slice(start, start + query.pageSize),\n total: deduped.length,\n page: query.page,\n pageSize: query.pageSize,\n })\n } catch (error) {\n if (isCrudHttpError(error)) {\n return NextResponse.json(error.body, { status: error.status })\n }\n if (error instanceof z.ZodError) {\n return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed') }, { status: 400 })\n }\n console.error('staff.assignable-team-members.get failed', error)\n return NextResponse.json({ error: translate('customers.errors.assignable_staff_load_failed', 'Failed to load assignable staff') }, { status: 500 })\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Assignable staff candidates',\n methods: {\n GET: {\n summary: 'List staff members that can be assigned from customer flows',\n query: querySchema,\n description:\n 'Returns active staff members linked to auth users. Access requires either customers.roles.manage or customers.activities.manage. Owned by the staff module; consumed from customer flows via this canonical URL. Replaces the deprecated /api/customers/assignable-staff route.',\n responses: [\n {\n status: 200,\n description: 'Assignable staff members',\n schema: pagedListSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid request', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 403, description: 'Forbidden', schema: errorSchema },\n ],\n },\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.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6407.1.9a4c26cf6a",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -248,16 +248,16 @@
|
|
|
248
248
|
"zod": "^4.4.3"
|
|
249
249
|
},
|
|
250
250
|
"peerDependencies": {
|
|
251
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
252
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
253
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
251
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6407.1.9a4c26cf6a",
|
|
252
|
+
"@open-mercato/shared": "0.6.6-develop.6407.1.9a4c26cf6a",
|
|
253
|
+
"@open-mercato/ui": "0.6.6-develop.6407.1.9a4c26cf6a",
|
|
254
254
|
"react": "^19.0.0",
|
|
255
255
|
"react-dom": "^19.0.0"
|
|
256
256
|
},
|
|
257
257
|
"devDependencies": {
|
|
258
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
259
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
260
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
258
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6407.1.9a4c26cf6a",
|
|
259
|
+
"@open-mercato/shared": "0.6.6-develop.6407.1.9a4c26cf6a",
|
|
260
|
+
"@open-mercato/ui": "0.6.6-develop.6407.1.9a4c26cf6a",
|
|
261
261
|
"@testing-library/dom": "^10.4.1",
|
|
262
262
|
"@testing-library/jest-dom": "^6.9.1",
|
|
263
263
|
"@testing-library/react": "^16.3.1",
|
|
@@ -259,7 +259,18 @@ export async function GET(req: Request) {
|
|
|
259
259
|
export async function POST(req: Request) {
|
|
260
260
|
const { t } = await resolveTranslations()
|
|
261
261
|
const auth = await getAuthFromRequest(req)
|
|
262
|
-
if (!auth || !auth.tenantId || !auth.orgId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
262
|
+
if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
263
|
+
// A superadmin browsing with "All organizations" selected has no concrete
|
|
264
|
+
// organization scope, but an attachment must be stored under exactly one
|
|
265
|
+
// organization (the scope invariant forbids partial-null rows). Reject with a
|
|
266
|
+
// clear, actionable error instead of a 401 — a 401 makes the client-side
|
|
267
|
+
// fetch layer treat it as session expiry, show a misleading toast, and reset
|
|
268
|
+
// the in-progress form (#3764).
|
|
269
|
+
if (!auth.orgId) {
|
|
270
|
+
return NextResponse.json({
|
|
271
|
+
error: t('attachments.errors.selectOrganization', 'Select a specific organization before uploading an attachment.'),
|
|
272
|
+
}, { status: 400 })
|
|
273
|
+
}
|
|
263
274
|
const tenantId = auth.tenantId
|
|
264
275
|
const orgId = auth.orgId
|
|
265
276
|
|
|
@@ -527,7 +538,7 @@ async function readTenantAttachmentUsageBytes(em: EntityManager, tenantId: strin
|
|
|
527
538
|
|
|
528
539
|
export async function DELETE(req: Request) {
|
|
529
540
|
const auth = await getAuthFromRequest(req)
|
|
530
|
-
if (!auth || !auth.tenantId || !auth.orgId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
541
|
+
if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
531
542
|
const url = new URL(req.url)
|
|
532
543
|
const id = url.searchParams.get('id') || ''
|
|
533
544
|
if (!id) return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
|
|
@@ -536,7 +547,12 @@ export async function DELETE(req: Request) {
|
|
|
536
547
|
const dataEngine = resolve('dataEngine')
|
|
537
548
|
const storageDriverFactory =
|
|
538
549
|
(resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)
|
|
539
|
-
|
|
550
|
+
// Mirror the GET handler's superadmin bypass: a superadmin browsing with "All
|
|
551
|
+
// organizations" selected has no concrete org, so scope the delete by tenant
|
|
552
|
+
// only and let them remove any attachment in the tenant. Non-superadmins stay
|
|
553
|
+
// scoped to their own organization (#3764).
|
|
554
|
+
const deleteFilter: Record<string, unknown> = { id, tenantId: auth.tenantId! }
|
|
555
|
+
if (auth.orgId) deleteFilter.organizationId = auth.orgId
|
|
540
556
|
const record = await em.findOne(Attachment, deleteFilter)
|
|
541
557
|
if (!record) return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
|
|
542
558
|
await em.remove(record).flush()
|
|
@@ -546,7 +562,7 @@ export async function DELETE(req: Request) {
|
|
|
546
562
|
if (record.storagePath) {
|
|
547
563
|
const delDriver = await storageDriverFactory.resolveForPartition(record.partitionCode, {
|
|
548
564
|
tenantId: record.tenantId ?? auth.tenantId!,
|
|
549
|
-
organizationId: record.organizationId ?? auth.orgId,
|
|
565
|
+
organizationId: record.organizationId ?? auth.orgId ?? '',
|
|
550
566
|
})
|
|
551
567
|
await delDriver.delete(record.partitionCode, record.storagePath)
|
|
552
568
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"attachments.errors.maxUploadSize": "Der Anhang überschreitet die maximal zulässige Upload-Größe.",
|
|
6
6
|
"attachments.errors.publicPartitionBlocked": "Öffentliche Speicherpartitionen können für diesen Upload nicht explizit ausgewählt werden.",
|
|
7
7
|
"attachments.errors.quotaExceeded": "Das Speicherlimit für Anhänge dieses Tenants wurde überschritten.",
|
|
8
|
+
"attachments.errors.selectOrganization": "Wählen Sie eine bestimmte Organisation aus, bevor Sie einen Anhang hochladen.",
|
|
8
9
|
"attachments.library.actions.copied": "Link kopiert.",
|
|
9
10
|
"attachments.library.actions.copyError": "Link konnte nicht kopiert werden.",
|
|
10
11
|
"attachments.library.actions.copyUrl": "URL kopieren",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"attachments.errors.maxUploadSize": "Attachment exceeds the maximum upload size.",
|
|
6
6
|
"attachments.errors.publicPartitionBlocked": "Public storage partitions cannot be selected explicitly for this upload.",
|
|
7
7
|
"attachments.errors.quotaExceeded": "Attachment storage quota exceeded for this tenant.",
|
|
8
|
+
"attachments.errors.selectOrganization": "Select a specific organization before uploading an attachment.",
|
|
8
9
|
"attachments.library.actions.copied": "Link copied.",
|
|
9
10
|
"attachments.library.actions.copyError": "Unable to copy link.",
|
|
10
11
|
"attachments.library.actions.copyUrl": "Copy URL",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"attachments.errors.maxUploadSize": "El archivo adjunto supera el tamaño máximo de carga permitido.",
|
|
6
6
|
"attachments.errors.publicPartitionBlocked": "No se pueden seleccionar explícitamente particiones de almacenamiento públicas para esta carga.",
|
|
7
7
|
"attachments.errors.quotaExceeded": "Se superó la cuota de almacenamiento de adjuntos para este tenant.",
|
|
8
|
+
"attachments.errors.selectOrganization": "Selecciona una organización específica antes de subir un adjunto.",
|
|
8
9
|
"attachments.library.actions.copied": "Enlace copiado.",
|
|
9
10
|
"attachments.library.actions.copyError": "No se pudo copiar el enlace.",
|
|
10
11
|
"attachments.library.actions.copyUrl": "Copiar URL",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"attachments.errors.maxUploadSize": "Załącznik przekracza maksymalny dopuszczalny rozmiar przesyłania.",
|
|
6
6
|
"attachments.errors.publicPartitionBlocked": "Nie można jawnie wybrać publicznej partycji do tego przesyłania.",
|
|
7
7
|
"attachments.errors.quotaExceeded": "Przekroczono limit miejsca na załączniki dla tego tenantu.",
|
|
8
|
+
"attachments.errors.selectOrganization": "Wybierz konkretną organizację przed przesłaniem załącznika.",
|
|
8
9
|
"attachments.library.actions.copied": "Link skopiowany.",
|
|
9
10
|
"attachments.library.actions.copyError": "Nie można skopiować linku.",
|
|
10
11
|
"attachments.library.actions.copyUrl": "Kopiuj URL",
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
|
+
import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
6
|
+
import {
|
|
7
|
+
runCrudMutationGuardAfterSuccess,
|
|
8
|
+
validateCrudMutationGuard,
|
|
9
|
+
} from '@open-mercato/shared/lib/crud/mutation-guard'
|
|
10
|
+
import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
11
|
+
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
12
|
+
import {
|
|
13
|
+
CATALOG_SETTINGS_MODULE_ID,
|
|
14
|
+
UNIT_PRICE_DISPLAY_ENABLED_DEFAULT,
|
|
15
|
+
UNIT_PRICE_DISPLAY_ENABLED_KEY,
|
|
16
|
+
} from '../../lib/settings'
|
|
17
|
+
|
|
18
|
+
export const metadata = {
|
|
19
|
+
GET: { requireAuth: true, requireFeatures: ['catalog.products.view'] },
|
|
20
|
+
PUT: { requireAuth: true, requireFeatures: ['catalog.settings.manage'] },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const bodySchema = z.object({
|
|
24
|
+
unitPriceDisplayEnabled: z.boolean(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const responseSchema = z.object({
|
|
28
|
+
unitPriceDisplayEnabled: z.boolean(),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
type SettingsContext = {
|
|
32
|
+
container: Awaited<ReturnType<typeof createRequestContainer>>
|
|
33
|
+
auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>
|
|
34
|
+
tenantId: string
|
|
35
|
+
organizationId: string | null
|
|
36
|
+
actorId: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function resolveSettingsContext(req: Request): Promise<SettingsContext> {
|
|
40
|
+
const container = await createRequestContainer()
|
|
41
|
+
const auth = await getAuthFromRequest(req)
|
|
42
|
+
if (!auth || !auth.tenantId) {
|
|
43
|
+
throw new CrudHttpError(401, { error: 'Unauthorized' })
|
|
44
|
+
}
|
|
45
|
+
const actorId =
|
|
46
|
+
(typeof auth.sub === 'string' && auth.sub.trim().length > 0 && auth.sub) ||
|
|
47
|
+
(typeof auth.userId === 'string' && auth.userId.trim().length > 0 && auth.userId) ||
|
|
48
|
+
(typeof auth.keyId === 'string' && auth.keyId.trim().length > 0 && auth.keyId) ||
|
|
49
|
+
'system'
|
|
50
|
+
return {
|
|
51
|
+
container,
|
|
52
|
+
auth,
|
|
53
|
+
tenantId: auth.tenantId,
|
|
54
|
+
organizationId: auth.orgId ?? null,
|
|
55
|
+
actorId,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readUnitPriceDisplayEnabled(context: SettingsContext): Promise<boolean> {
|
|
60
|
+
const configService = context.container.resolve('moduleConfigService') as ModuleConfigService
|
|
61
|
+
const value = await configService.getValue<boolean>(
|
|
62
|
+
CATALOG_SETTINGS_MODULE_ID,
|
|
63
|
+
UNIT_PRICE_DISPLAY_ENABLED_KEY,
|
|
64
|
+
{ defaultValue: UNIT_PRICE_DISPLAY_ENABLED_DEFAULT, scope: { tenantId: context.tenantId } },
|
|
65
|
+
)
|
|
66
|
+
return typeof value === 'boolean' ? value : UNIT_PRICE_DISPLAY_ENABLED_DEFAULT
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function GET(req: Request) {
|
|
70
|
+
try {
|
|
71
|
+
const context = await resolveSettingsContext(req)
|
|
72
|
+
const unitPriceDisplayEnabled = await readUnitPriceDisplayEnabled(context)
|
|
73
|
+
return NextResponse.json({ unitPriceDisplayEnabled })
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (isCrudHttpError(err)) {
|
|
76
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
77
|
+
}
|
|
78
|
+
console.error('[catalog/settings.GET] Unexpected error', err)
|
|
79
|
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function PUT(req: Request) {
|
|
84
|
+
try {
|
|
85
|
+
const context = await resolveSettingsContext(req)
|
|
86
|
+
const body = bodySchema.parse(await req.json())
|
|
87
|
+
|
|
88
|
+
const guardResult = await validateCrudMutationGuard(context.container, {
|
|
89
|
+
tenantId: context.tenantId,
|
|
90
|
+
organizationId: context.organizationId,
|
|
91
|
+
userId: context.actorId,
|
|
92
|
+
resourceKind: 'catalog.settings',
|
|
93
|
+
resourceId: UNIT_PRICE_DISPLAY_ENABLED_KEY,
|
|
94
|
+
operation: 'custom',
|
|
95
|
+
requestMethod: req.method,
|
|
96
|
+
requestHeaders: req.headers,
|
|
97
|
+
mutationPayload: { unitPriceDisplayEnabled: body.unitPriceDisplayEnabled },
|
|
98
|
+
})
|
|
99
|
+
if (guardResult && !guardResult.ok) {
|
|
100
|
+
return NextResponse.json(guardResult.body, { status: guardResult.status })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const configService = context.container.resolve('moduleConfigService') as ModuleConfigService
|
|
104
|
+
await configService.setValue(
|
|
105
|
+
CATALOG_SETTINGS_MODULE_ID,
|
|
106
|
+
UNIT_PRICE_DISPLAY_ENABLED_KEY,
|
|
107
|
+
body.unitPriceDisplayEnabled,
|
|
108
|
+
{ tenantId: context.tenantId },
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
|
|
112
|
+
await runCrudMutationGuardAfterSuccess(context.container, {
|
|
113
|
+
tenantId: context.tenantId,
|
|
114
|
+
organizationId: context.organizationId,
|
|
115
|
+
userId: context.actorId,
|
|
116
|
+
resourceKind: 'catalog.settings',
|
|
117
|
+
resourceId: UNIT_PRICE_DISPLAY_ENABLED_KEY,
|
|
118
|
+
operation: 'custom',
|
|
119
|
+
requestMethod: req.method,
|
|
120
|
+
requestHeaders: req.headers,
|
|
121
|
+
metadata: guardResult.metadata ?? null,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return NextResponse.json({ unitPriceDisplayEnabled: body.unitPriceDisplayEnabled })
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (isCrudHttpError(err)) {
|
|
128
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
129
|
+
}
|
|
130
|
+
if (err instanceof z.ZodError) {
|
|
131
|
+
return NextResponse.json({ error: 'Invalid request', details: err.issues }, { status: 400 })
|
|
132
|
+
}
|
|
133
|
+
console.error('[catalog/settings.PUT] Unexpected error', err)
|
|
134
|
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const getDoc: OpenApiMethodDoc = {
|
|
139
|
+
summary: 'Read catalog settings',
|
|
140
|
+
tags: ['Catalog'],
|
|
141
|
+
responses: [
|
|
142
|
+
{ status: 200, description: 'Catalog settings', schema: responseSchema },
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const putDoc: OpenApiMethodDoc = {
|
|
147
|
+
summary: 'Update catalog settings',
|
|
148
|
+
tags: ['Catalog'],
|
|
149
|
+
requestBody: { schema: bodySchema },
|
|
150
|
+
responses: [
|
|
151
|
+
{ status: 200, description: 'Updated catalog settings', schema: responseSchema },
|
|
152
|
+
],
|
|
153
|
+
errors: [{ status: 400, description: 'Invalid request body' }],
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const openApi: OpenApiRouteDoc = {
|
|
157
|
+
tag: 'Catalog',
|
|
158
|
+
summary: 'Catalog module settings',
|
|
159
|
+
methods: {
|
|
160
|
+
GET: getDoc,
|
|
161
|
+
PUT: putDoc,
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export { GET, PUT }
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
2
2
|
import { PriceKindSettings } from '../../../components/PriceKindSettings'
|
|
3
|
+
import { UnitPriceDisplaySettings } from '../../../components/UnitPriceDisplaySettings'
|
|
3
4
|
|
|
4
5
|
export default function CatalogConfigurationPage() {
|
|
5
6
|
return (
|
|
6
7
|
<Page>
|
|
7
8
|
<PageBody className="space-y-8">
|
|
8
9
|
<PriceKindSettings />
|
|
10
|
+
<UnitPriceDisplaySettings />
|
|
9
11
|
</PageBody>
|
|
10
12
|
</Page>
|
|
11
13
|
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { SwitchField } from '@open-mercato/ui/primitives/switch-field'
|
|
5
|
+
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
6
|
+
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
7
|
+
import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
8
|
+
import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
9
|
+
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
10
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
11
|
+
|
|
12
|
+
type CatalogSettingsResponse = {
|
|
13
|
+
unitPriceDisplayEnabled?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function UnitPriceDisplaySettings() {
|
|
17
|
+
const t = useT()
|
|
18
|
+
const scopeVersion = useOrganizationScopeVersion()
|
|
19
|
+
const [enabled, setEnabled] = React.useState<boolean | null>(null)
|
|
20
|
+
const [saving, setSaving] = React.useState(false)
|
|
21
|
+
|
|
22
|
+
const loadError = t('catalog.settings.unitPriceDisplay.errors.load', 'Failed to load catalog settings.')
|
|
23
|
+
|
|
24
|
+
const load = React.useCallback(async () => {
|
|
25
|
+
try {
|
|
26
|
+
const payload = await readApiResultOrThrow<CatalogSettingsResponse>(
|
|
27
|
+
'/api/catalog/settings',
|
|
28
|
+
undefined,
|
|
29
|
+
{ errorMessage: loadError, fallback: { unitPriceDisplayEnabled: true } },
|
|
30
|
+
)
|
|
31
|
+
setEnabled(payload.unitPriceDisplayEnabled !== false)
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error('catalog.settings.load failed', err)
|
|
34
|
+
flash(loadError, 'error')
|
|
35
|
+
setEnabled(true)
|
|
36
|
+
}
|
|
37
|
+
}, [loadError])
|
|
38
|
+
|
|
39
|
+
React.useEffect(() => {
|
|
40
|
+
load().catch(() => {})
|
|
41
|
+
}, [load, scopeVersion])
|
|
42
|
+
|
|
43
|
+
const handleChange = React.useCallback(async (next: boolean) => {
|
|
44
|
+
const previous = enabled
|
|
45
|
+
setEnabled(next)
|
|
46
|
+
setSaving(true)
|
|
47
|
+
try {
|
|
48
|
+
// optimistic-lock-exempt: tenant-scoped module-config toggle (single
|
|
49
|
+
// settings row via ModuleConfigService), not a versioned editable entity —
|
|
50
|
+
// no updated_at to lock against and no lost-update concern for this admin preference.
|
|
51
|
+
const call = await apiCall('/api/catalog/settings', {
|
|
52
|
+
method: 'PUT',
|
|
53
|
+
headers: { 'content-type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ unitPriceDisplayEnabled: next }),
|
|
55
|
+
})
|
|
56
|
+
if (!call.ok) {
|
|
57
|
+
await raiseCrudError(call.response, t('catalog.settings.unitPriceDisplay.errors.save', 'Failed to save catalog settings.'))
|
|
58
|
+
}
|
|
59
|
+
flash(t('catalog.settings.messages.saved', 'Settings saved.'), 'success')
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error('catalog.settings.save failed', err)
|
|
62
|
+
setEnabled(previous)
|
|
63
|
+
const message = err instanceof Error
|
|
64
|
+
? err.message
|
|
65
|
+
: t('catalog.settings.unitPriceDisplay.errors.save', 'Failed to save catalog settings.')
|
|
66
|
+
flash(message, 'error')
|
|
67
|
+
} finally {
|
|
68
|
+
setSaving(false)
|
|
69
|
+
}
|
|
70
|
+
}, [enabled, t])
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<section className="border bg-card text-card-foreground shadow-sm">
|
|
74
|
+
<div className="border-b px-6 py-4 space-y-1">
|
|
75
|
+
<h2 className="text-lg font-semibold">
|
|
76
|
+
{t('catalog.settings.unitPriceDisplay.title', 'Unit price presentation')}
|
|
77
|
+
</h2>
|
|
78
|
+
<p className="text-sm text-muted-foreground">
|
|
79
|
+
{t(
|
|
80
|
+
'catalog.settings.unitPriceDisplay.description',
|
|
81
|
+
'Controls whether the EU unit price presentation feature is available on the product form.',
|
|
82
|
+
)}
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
<div className="px-6 py-4">
|
|
86
|
+
{enabled === null ? (
|
|
87
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
88
|
+
<Spinner className="h-4 w-4" />
|
|
89
|
+
{t('catalog.settings.loading', 'Loading…')}
|
|
90
|
+
</div>
|
|
91
|
+
) : (
|
|
92
|
+
<SwitchField
|
|
93
|
+
label={t('catalog.settings.unitPriceDisplay.toggleLabel', 'Enable EU unit price presentation')}
|
|
94
|
+
description={t(
|
|
95
|
+
'catalog.settings.unitPriceDisplay.toggleDescription',
|
|
96
|
+
'When off, the EU unit price settings are hidden from every product form. Turn this off for manufacturers or other tenants that do not sell to consumers.',
|
|
97
|
+
)}
|
|
98
|
+
checked={enabled}
|
|
99
|
+
disabled={saving}
|
|
100
|
+
onCheckedChange={(next) => { void handleChange(next) }}
|
|
101
|
+
/>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
</section>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
import { createProductUnitConversionDraft } from "./productForm";
|
|
24
24
|
import { REFERENCE_UNIT_CODES } from "@open-mercato/shared/lib/units/unitCodes";
|
|
25
25
|
import { toTrimmedOrNull } from "./productFormUtils";
|
|
26
|
+
import { useUnitPriceDisplayEnabled } from "./hooks/useUnitPriceDisplayEnabled";
|
|
26
27
|
|
|
27
28
|
type UnitDictionaryEntry = {
|
|
28
29
|
id?: string;
|
|
@@ -134,6 +135,7 @@ export function ProductUomSection({
|
|
|
134
135
|
embedded = false,
|
|
135
136
|
}: ProductUomSectionProps) {
|
|
136
137
|
const t = useT();
|
|
138
|
+
const { enabled: unitPriceDisplayEnabled } = useUnitPriceDisplayEnabled();
|
|
137
139
|
const [unitOptions, setUnitOptions] = React.useState<UnitOption[]>([]);
|
|
138
140
|
const [loadingUnits, setLoadingUnits] = React.useState(false);
|
|
139
141
|
const [errorLoadingUnits, setErrorLoadingUnits] = React.useState(false);
|
|
@@ -473,6 +475,7 @@ export function ProductUomSection({
|
|
|
473
475
|
</div>
|
|
474
476
|
</div>
|
|
475
477
|
|
|
478
|
+
{unitPriceDisplayEnabled ? (
|
|
476
479
|
<div className="space-y-3">
|
|
477
480
|
<div className="flex items-center gap-2">
|
|
478
481
|
<Checkbox
|
|
@@ -564,6 +567,7 @@ export function ProductUomSection({
|
|
|
564
567
|
</p>
|
|
565
568
|
) : null}
|
|
566
569
|
</div>
|
|
570
|
+
) : null}
|
|
567
571
|
|
|
568
572
|
<div className="space-y-3">
|
|
569
573
|
<div className="flex items-center justify-between gap-2">
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useQuery } from '@tanstack/react-query'
|
|
4
|
+
import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
5
|
+
|
|
6
|
+
type CatalogSettingsResponse = {
|
|
7
|
+
unitPriceDisplayEnabled?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type UseUnitPriceDisplayEnabledResult = {
|
|
11
|
+
enabled: boolean
|
|
12
|
+
isLoading: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves the tenant-scoped catalog setting that controls whether the EU unit
|
|
17
|
+
* price presentation feature is available on the product form. Defaults to
|
|
18
|
+
* enabled while loading (and on failure) so the common retail case is never
|
|
19
|
+
* hidden by a transient error.
|
|
20
|
+
*/
|
|
21
|
+
export function useUnitPriceDisplayEnabled(): UseUnitPriceDisplayEnabledResult {
|
|
22
|
+
const query = useQuery({
|
|
23
|
+
queryKey: ['catalog', 'settings', 'unitPriceDisplayEnabled'],
|
|
24
|
+
queryFn: async () => {
|
|
25
|
+
const result = await readApiResultOrThrow<CatalogSettingsResponse>(
|
|
26
|
+
'/api/catalog/settings',
|
|
27
|
+
undefined,
|
|
28
|
+
{ errorMessage: 'Failed to load catalog settings.', fallback: { unitPriceDisplayEnabled: true } },
|
|
29
|
+
)
|
|
30
|
+
return result.unitPriceDisplayEnabled !== false
|
|
31
|
+
},
|
|
32
|
+
staleTime: 60_000,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
enabled: query.data ?? true,
|
|
37
|
+
isLoading: query.isLoading,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -614,6 +614,14 @@
|
|
|
614
614
|
"catalog.search.priceKind.promotion": "Aktion",
|
|
615
615
|
"catalog.search.status.inactive": "Inaktiv",
|
|
616
616
|
"catalog.search.variant.default": "Standard",
|
|
617
|
+
"catalog.settings.loading": "Wird geladen…",
|
|
618
|
+
"catalog.settings.messages.saved": "Einstellungen gespeichert.",
|
|
619
|
+
"catalog.settings.unitPriceDisplay.description": "Legt fest, ob die EU-Grundpreisdarstellung im Produktformular verfügbar ist.",
|
|
620
|
+
"catalog.settings.unitPriceDisplay.errors.load": "Katalogeinstellungen konnten nicht geladen werden.",
|
|
621
|
+
"catalog.settings.unitPriceDisplay.errors.save": "Katalogeinstellungen konnten nicht gespeichert werden.",
|
|
622
|
+
"catalog.settings.unitPriceDisplay.title": "Grundpreisdarstellung",
|
|
623
|
+
"catalog.settings.unitPriceDisplay.toggleDescription": "Wenn deaktiviert, werden die EU-Grundpreiseinstellungen in jedem Produktformular ausgeblendet. Deaktivieren Sie dies für Hersteller oder andere Mandanten, die nicht an Verbraucher verkaufen.",
|
|
624
|
+
"catalog.settings.unitPriceDisplay.toggleLabel": "EU-Grundpreisdarstellung aktivieren",
|
|
617
625
|
"catalog.stats.active": "Aktiv",
|
|
618
626
|
"catalog.stats.categories": "Kategorien",
|
|
619
627
|
"catalog.stats.products": "Produkte",
|
|
@@ -614,6 +614,14 @@
|
|
|
614
614
|
"catalog.search.priceKind.promotion": "Promotion",
|
|
615
615
|
"catalog.search.status.inactive": "Inactive",
|
|
616
616
|
"catalog.search.variant.default": "Default",
|
|
617
|
+
"catalog.settings.loading": "Loading…",
|
|
618
|
+
"catalog.settings.messages.saved": "Settings saved.",
|
|
619
|
+
"catalog.settings.unitPriceDisplay.description": "Controls whether the EU unit price presentation feature is available on the product form.",
|
|
620
|
+
"catalog.settings.unitPriceDisplay.errors.load": "Failed to load catalog settings.",
|
|
621
|
+
"catalog.settings.unitPriceDisplay.errors.save": "Failed to save catalog settings.",
|
|
622
|
+
"catalog.settings.unitPriceDisplay.title": "Unit price presentation",
|
|
623
|
+
"catalog.settings.unitPriceDisplay.toggleDescription": "When off, the EU unit price settings are hidden from every product form. Turn this off for manufacturers or other tenants that do not sell to consumers.",
|
|
624
|
+
"catalog.settings.unitPriceDisplay.toggleLabel": "Enable EU unit price presentation",
|
|
617
625
|
"catalog.stats.active": "Active",
|
|
618
626
|
"catalog.stats.categories": "Categories",
|
|
619
627
|
"catalog.stats.products": "Products",
|